Compare commits

...
585 Commits
Author SHA1 Message Date
Vitor Pamplona 0c48366d56 v0.80.2 2024-06-01 16:12:49 -04:00
Vitor Pamplona f1834af506 Moves the filter for no subscriptions to the DAL 2024-06-01 15:31:43 -04:00
Vitor Pamplona f26fa71e49 improves the speed of the kind filter for DVMs 2024-06-01 15:20:26 -04:00
Vitor Pamplona 4faee68174 Filters by supported kinds in the DVM view. 2024-06-01 15:16:35 -04:00
Vitor Pamplona 2326e66382 Solves race conditions when connecting to relays. 2024-06-01 15:16:18 -04:00
Vitor PamplonaandGitHub bf71365f51 Merge pull request #892 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 13:18:30 -04:00
Crowdin Bot f45e8d491c New Crowdin translations by GitHub Action 2024-06-01 17:17:09 +00:00
Vitor Pamplona 5a476cab32 v0.87.0 2024-06-01 13:15:27 -04:00
Vitor Pamplona b5b0a19df5 Fixes the disappearance of the notification icon 2024-06-01 13:14:30 -04:00
Vitor PamplonaandGitHub 85d26dee08 Merge pull request #891 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 10:58:27 -04:00
Crowdin Bot 476da07d3d New Crowdin translations by GitHub Action 2024-06-01 14:55:45 +00:00
Vitor Pamplona 3b7344e894 v0.87.0 2024-06-01 10:53:39 -04:00
Vitor Pamplona d307bb0e49 Fixes missing relays certain internal lists 2024-05-31 19:47:40 -04:00
Vitor Pamplona 1bbcb97d54 Fixes read status between others and the logged in user 2024-05-31 16:54:18 -04:00
Vitor Pamplona 3a9637ccb9 Optimizes Blurhash generation 2024-05-31 14:24:55 -04:00
Vitor Pamplona 071da53a6a Waits 500ms before restarting all connections. 2024-05-30 17:11:11 -04:00
Vitor Pamplona 9d190f93bb deactivate write for search relays (they are read only) 2024-05-30 17:10:56 -04:00
Vitor Pamplona 7c65a93896 - Only updates lists if they were changed.
- Refactoring RelayLists
2024-05-30 17:10:38 -04:00
Vitor Pamplona d9b96d2fd5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 16:20:00 -04:00
Vitor Pamplona 3333fdc237 Layout improvements for the relay screen 2024-05-30 16:19:52 -04:00
Vitor Pamplona e178ff988d Adds NIP-50 requirements to the Search explainer. 2024-05-30 16:19:39 -04:00
Vitor PamplonaandGitHub df649445d0 Merge pull request #890 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 16:02:37 -04:00
Vitor Pamplona a38346c7d3 Adds private outbox relays to save Draft events. 2024-05-30 16:02:21 -04:00
Crowdin Bot 71da587426 New Crowdin translations by GitHub Action 2024-05-30 19:58:38 +00:00
Vitor PamplonaandGitHub 6ecb5ecc47 Merge pull request #889 from believethehype/main
show error messages if dvm status returns error
2024-05-30 15:57:23 -04:00
Believethehype b2a19efcd0 show error messages, if dvm status returns error 2024-05-30 20:12:06 +02:00
Vitor Pamplona 1123586ef6 Uses DM Relay List to activate nip17 messages in chats. 2024-05-30 14:00:20 -04:00
Vitor Pamplona ab037e61aa adds local relays to the connecting set 2024-05-30 13:51:31 -04:00
Vitor Pamplona 4a43b57f79 Uses NIP-65 preferably for zap requests. 2024-05-30 13:51:14 -04:00
Vitor Pamplona f81c83326c Relay screen padding adjustments 2024-05-30 13:33:11 -04:00
Vitor Pamplona fdd1dd1855 Adds a title to the relay settings screen 2024-05-30 12:44:29 -04:00
Vitor Pamplona 9f3d4db9dd Adds Local Relays to the relay list screen, saving locally in the device. 2024-05-30 12:35:24 -04:00
Vitor Pamplona c23a9f8ad6 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 11:21:47 -04:00
Vitor Pamplona 36b0c8acb5 Fixing crash when calling localcache on the main thread. 2024-05-30 11:21:39 -04:00
Vitor PamplonaandGitHub dc12789166 Merge pull request #888 from KotlinGeekDev/unifiedpush-updates
UnifiedPush library updates
2024-05-30 11:20:38 -04:00
KotlinGeekDevandGitHub 942b0fa531 Merge branch 'vitorpamplona:main' into unifiedpush-updates 2024-05-30 15:07:48 +00:00
KotlinGeekDev 8ae7f31c3d Refactor getSavedDistributor(). It uses UnifiedPush.getSavedDistributor underneath to preserve pre-API change behaviour. 2024-05-30 16:04:30 +01:00
KotlinGeekDev bc1bbc2f70 Update to up-connector to v2.3.1. The JVM toolchain and Kotlin downgrade in 2.4.0 may not be good for Amethyst now. Will upgrade when everything has checked out. 2024-05-30 16:01:22 +01:00
Vitor Pamplona 632c78792a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 09:55:00 -04:00
Vitor Pamplona e2a719164f Migrates Kotlin and dependencies to the latest version 2024-05-30 09:48:45 -04:00
Vitor Pamplona ff94f45d04 Enabling download and creation of NIP-96 file servers. 2024-05-30 09:38:33 -04:00
Vitor Pamplona 7e1b1c7d2c No need for experimental tag anymore. 2024-05-30 09:37:15 -04:00
Vitor PamplonaandGitHub f6f9d82665 Merge pull request #887 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 08:41:51 -04:00
Crowdin Bot 3638059c23 New Crowdin translations by GitHub Action 2024-05-30 12:41:41 +00:00
Vitor Pamplona a781e733f2 improving the filter for the request id of the cached response. 2024-05-30 08:39:23 -04:00
Vitor PamplonaandGitHub 56d6376efc Merge pull request #886 from believethehype/main
Check if NIP90 Result exists before relying on Cache
2024-05-30 08:34:59 -04:00
Vitor PamplonaandGitHub 3ac9d3a4f7 Merge pull request #885 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 08:33:49 -04:00
Believethehype 521da9a774 check if NIP90 Result list exists before relying on Cache 2024-05-30 14:23:24 +02:00
Crowdin Bot 18ebdaf96d New Crowdin translations by GitHub Action 2024-05-30 10:50:40 +00:00
Vitor PamplonaandGitHub 19d394a383 Merge pull request #884 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 06:49:20 -04:00
Crowdin Bot b198b4c081 New Crowdin translations by GitHub Action 2024-05-30 10:47:55 +00:00
Vitor PamplonaandGitHub 28bc46f120 Merge pull request #883 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 06:46:21 -04:00
Crowdin Bot 7a6de4ea49 New Crowdin translations by GitHub Action 2024-05-30 10:33:52 +00:00
Vitor PamplonaandGitHub bc108875e6 Merge pull request #882 from davotoula/update_tr
Update SE, CZ, DE, PT translations
2024-05-30 06:32:33 -04:00
David Kaspar aa38b41c58 Update SE, CZ, DE, PT translations 2024-05-30 10:01:33 +02:00
Vitor Pamplona a4c26b1fd8 Reducing the use of backgrounds to improve rendering speeds. 2024-05-29 18:53:26 -04:00
Vitor Pamplona 550cfe8bb4 Fixes relay list assembling 2024-05-29 15:58:47 -04:00
Vitor Pamplona 56664afa4e Show missing relays for DMs when the DMRelayList event is empty. 2024-05-29 15:22:19 -04:00
Vitor Pamplona 90c9deecd4 Removes the forced search relay selection and helps users setup their relay list for search. 2024-05-29 15:21:54 -04:00
Vitor Pamplona add65b094d updates junit 2024-05-29 14:28:07 -04:00
Vitor Pamplona c7a4161799 Fixing tests 2024-05-29 12:50:46 -04:00
Vitor Pamplona f6a16e4394 Fixes date format's year 2024-05-29 12:28:22 -04:00
Vitor Pamplona 44a615020d Improves wording of the relay list 2024-05-29 12:28:10 -04:00
Vitor Pamplona 288aafc3a2 Adds support for search relay lists 2024-05-29 12:06:45 -04:00
Vitor Pamplona aa97c7ee9b Adds NIP-65 relay settings 2024-05-29 10:39:08 -04:00
Vitor Pamplona 51a6eb4974 - Adds a new observer by kind and author
- Uses NIP-17 relays for DMs
- Changes default connection to account from NIP17 relays
- Only sends wraps to the appropriate relays.
2024-05-28 19:13:03 -04:00
Vitor Pamplona f8dfc06a06 Saves DMs and All Relays on the relay list 2024-05-28 19:11:23 -04:00
Vitor Pamplona 2d4db9fea8 Refactoring on latestbykindandetag 2024-05-28 19:09:53 -04:00
Vitor Pamplona 34b743146e Making sure the relay list has different keys for different sections. 2024-05-28 18:48:41 -04:00
Vitor Pamplona 13eb8b1abc Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-28 11:49:20 -04:00
Vitor Pamplona e98a77b37f Adds DM relays to the relay list. 2024-05-28 11:49:13 -04:00
Vitor Pamplona 6a17a8a871 Refactors GiftWrap caching to delete encrypted text and reload the wrap if necessary.
- Changes host to a host stub to reduce memory use
- Only download GiftWraps form 2 days past the last EOSE
2024-05-28 11:48:20 -04:00
Vitor PamplonaandGitHub 3ce726bbb7 Merge pull request #881 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-28 08:27:01 -04:00
Crowdin Bot 593bf24f5b New Crowdin translations by GitHub Action 2024-05-28 12:22:25 +00:00
Vitor PamplonaandGitHub 7a857711eb Merge pull request #880 from davotoula/update_tr
Update SE, CZ, DE, PT translations
2024-05-28 08:20:52 -04:00
David Kaspar 420e325d1f Update SE, CZ, DE, PT translations 2024-05-28 09:04:49 +02:00
Vitor PamplonaandGitHub 610657a9b6 Merge pull request #877 from greenart7c3/main
add signString method
2024-05-27 08:15:34 -04:00
greenart7c3 ea891367e9 add signString method 2024-05-27 07:56:02 -03:00
Vitor Pamplona 0c22e66e8f Overrides pubkey to avoid impersonators on seals. 2024-05-24 19:27:14 -04:00
Vitor Pamplona cd008d78a0 Improves the debug of the memory use per event kind 2024-05-24 19:26:39 -04:00
Vitor Pamplona 2c2e4a1694 Clears flows 2024-05-24 19:26:13 -04:00
Vitor Pamplona d7790cd31e Moves the thread formatter and calculator out of Note 2024-05-24 18:11:57 -04:00
Vitor Pamplona 0bb571b52e Fixes position of the edit field after the relay list 2024-05-24 17:42:42 -04:00
Vitor PamplonaandGitHub c65a4b8fe7 Merge pull request #876 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-24 14:59:55 -04:00
Crowdin Bot abb0c73c71 New Crowdin translations by GitHub Action 2024-05-24 18:56:33 +00:00
Vitor Pamplona 224bcf97ec Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-24 14:54:48 -04:00
Vitor Pamplona 04c449072a Adds support for NIP-06 seed word key derivation (bip32 and bip39) 2024-05-24 14:54:38 -04:00
Vitor PamplonaandGitHub b26aba92a1 Merge pull request #875 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-24 09:12:25 -04:00
Crowdin Bot 5ce9df9dce New Crowdin translations by GitHub Action 2024-05-24 12:20:21 +00:00
Vitor Pamplona 7426600dc6 deleting orangepill since it might not come back 2024-05-24 08:18:32 -04:00
Vitor Pamplona 9d4a88d26f Clear up some old unstable api tags 2024-05-23 18:18:46 -04:00
Vitor Pamplona 85aaf25561 Remove unnecessary logs 2024-05-23 17:11:18 -04:00
Vitor Pamplona 1cd5845960 Fixes the rendering of replies on wikipages. 2024-05-23 17:09:16 -04:00
Vitor Pamplona e38f6be20c Refactoring the composables for the relay list screen 2024-05-23 16:40:24 -04:00
Vitor Pamplona c78c00acd9 Adds method to create the NIP-65 list 2024-05-22 18:23:52 -04:00
Vitor Pamplona 11fa762028 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-22 17:46:45 -04:00
Vitor Pamplona 599fddb369 Adds a card to setup the DM relay list if the user doesn't have one. 2024-05-22 17:46:39 -04:00
Vitor Pamplona 76103ac057 Fixes the padding of the x button on the relay list screen 2024-05-22 17:45:55 -04:00
Vitor Pamplona 6538cbd9da Fixes padding of the chat list elements. 2024-05-22 17:45:18 -04:00
Vitor Pamplona 566ca0e390 Moves Relay URL formatter to Quartz 2024-05-22 17:44:18 -04:00
Vitor PamplonaandGitHub c5f7ea801d Update README.md
Adding the most recent feature sets
2024-05-22 14:54:21 -04:00
Vitor Pamplona 469e7abd99 Clears imeta tags that might duplicate for the same post. 2024-05-22 13:43:07 -04:00
Vitor Pamplona e540954125 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-21 15:56:58 -04:00
Vitor Pamplona f73052ed48 Checks cache before making a new request to DVM 2024-05-21 15:56:51 -04:00
Vitor Pamplona a9437d9001 Refactors CreatedAt Comparator 2024-05-21 15:56:33 -04:00
Vitor PamplonaandGitHub 02f6422bab Merge pull request #871 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-21 14:53:09 -04:00
Crowdin Bot da2b59af49 New Crowdin translations by GitHub Action 2024-05-21 18:52:37 +00:00
Vitor Pamplona 7acdf56e68 Slightly improving the threading for badges. 2024-05-21 14:50:36 -04:00
Vitor Pamplona 24be6cd90d No need to launch, just use IO 2024-05-21 12:26:39 -04:00
Vitor PamplonaandGitHub afee0ddc53 Merge pull request #869 from believethehype/NIP90-ContentDiscovery
update paid dvms with processing status after payment
2024-05-21 08:41:38 -04:00
Believethehype c85b8a1d83 update paid dvms with processing status after payment 2024-05-21 12:11:44 +02:00
Vitor Pamplona cfeedfa4e2 Fixes the use of cache for video files. 2024-05-20 17:42:21 -04:00
Vitor Pamplona dd112d28ae updating some libraries (others are not ready for the app) 2024-05-20 17:11:03 -04:00
Vitor Pamplona fc27526113 Removes reposts from the Dot Notification in the home's bottom bar icon 2024-05-20 16:45:08 -04:00
Vitor Pamplona 666635811b Migrating to AGP 8.4.1 2024-05-20 16:42:11 -04:00
Vitor Pamplona 4e43938f96 Can zap DVMs directly on the regular zap button. 2024-05-20 16:22:27 -04:00
Vitor PamplonaandGitHub 2a744205f0 Merge pull request #868 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-20 15:20:33 -04:00
Crowdin Bot 7e6ca34d2a New Crowdin translations by GitHub Action 2024-05-20 19:16:35 +00:00
Vitor Pamplona 24f7991116 Fixes a white space after a new line 2024-05-20 15:15:06 -04:00
Vitor Pamplona beb901120e Fixes call from main thread 2024-05-20 13:25:53 -04:00
Vitor Pamplona 0936df9851 Fixes sat division and the state changing text. 2024-05-20 13:19:04 -04:00
Vitor Pamplona 9ceb8866ed Refactoring the DVM payment code. 2024-05-20 12:37:55 -04:00
Vitor PamplonaandGitHub c88b21b547 Merge pull request #867 from believethehype/NIP90-ContentDiscovery
Nip90 content discovery - Consider status and amount tags
2024-05-20 11:38:13 -04:00
Vitor PamplonaandGitHub 5c366d5cfc Merge branch 'main' into NIP90-ContentDiscovery 2024-05-20 11:37:07 -04:00
Vitor Pamplona ea70d44ac7 liniting 2024-05-20 11:24:03 -04:00
Believethehype 67f10920f6 Update NIP90ContentDiscoveryScreen.kt 2024-05-20 00:35:39 +02:00
Believethehype 654632a585 Update NIP90ContentDiscoveryScreen.kt 2024-05-20 00:32:26 +02:00
Believethehype 794b05106b add possibility to zap/pay invoices for dvms 2024-05-20 00:15:30 +02:00
Vitor PamplonaandGitHub 5812e290c9 Merge pull request #866 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-17 18:32:35 -04:00
Crowdin Bot c279c04858 New Crowdin translations by GitHub Action 2024-05-17 22:32:08 +00:00
Vitor Pamplona 01016525d3 Fixes missing parameter 2024-05-17 18:30:48 -04:00
Vitor Pamplona 72c6e93524 Migrates DVM route from pubkey to AppDefinition eventId and makes sure NFC reading will load the AppDefinition itself before rendering the rest of the screen. 2024-05-17 18:06:20 -04:00
Vitor Pamplona d6988ad4e1 fixes redirection of routeFor when passing an App Definition Event. 2024-05-17 18:05:38 -04:00
Vitor Pamplona 4c1cd1c9ab adds DVM picture and name to the top bar and the status feed. 2024-05-17 17:18:31 -04:00
Vitor Pamplona 9fb8d4821e Refactoring the DVM codebase
Allows pull to refresh to request the job again.
2024-05-17 17:02:04 -04:00
Vitor Pamplona 8b052567c4 Adds a new observable system to local cache. 2024-05-17 17:01:57 -04:00
Vitor Pamplona 4a6ea550d6 Fixes the spacing in the Like button 2024-05-17 17:00:19 -04:00
Vitor Pamplona 5c88e7993f Fixes some of the missing classes due to R8 2024-05-16 15:14:51 -04:00
Vitor PamplonaandGitHub 1bd1493bf4 Merge pull request #864 from greenart7c3/main
add paste from clipboard button to nwc
2024-05-16 13:47:19 -04:00
greenart7c3 404278a4e3 add paste from clipboard button to nwc 2024-05-16 14:13:40 -03:00
Vitor Pamplona edca55b0b6 must not use crypto tools on the benchmark 2024-05-16 13:10:38 -04:00
Vitor Pamplona 738187d32b Making generateID public for testing 2024-05-16 13:05:15 -04:00
Vitor Pamplona d9de0d2798 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-16 13:04:17 -04:00
Vitor PamplonaandGitHub b20515b1a0 Merge pull request #862 from believethehype/NIP90-ContentDiscovery
NIP90: cleanup, potential fix endless loop
2024-05-16 13:04:02 -04:00
Vitor Pamplona e6d8291f07 Trying to unify NIP01 Serialization with SHA-256 procedures to reduce creation of several bytearray at every verification. 2024-05-16 13:03:30 -04:00
Vitor Pamplona 18d08bf6e0 Don't obfuscate data classes. 2024-05-16 13:01:15 -04:00
Believethehype b2193f48d5 Update NIP90ContentDiscoveryScreen.kt 2024-05-16 16:48:19 +02:00
Vitor PamplonaandGitHub de391f03b1 Merge pull request #858 from VASHvic/auto_focus_search_screen
auto focus when entering the search screen
2024-05-16 09:29:21 -04:00
Believethehype c494cf8ac1 Update NIP90ContentDiscoveryScreen.kt 2024-05-16 15:20:04 +02:00
Believethehype 193e9a5adb add more info on current dvm state 2024-05-16 15:19:19 +02:00
Believethehype 4fb9c93cf0 Merge branch 'NIP90-ContentDiscovery' of https://github.com/believethehype/amethyst into NIP90-ContentDiscovery 2024-05-16 15:16:33 +02:00
Believethehype 6bd98201f8 add DVM not replying message 2024-05-16 15:16:30 +02:00
believethehypeandGitHub cbc6697631 Merge branch 'main' into NIP90-ContentDiscovery 2024-05-16 15:08:42 +02:00
Vitor PamplonaandGitHub d48634ac0e Merge pull request #861 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-16 09:03:48 -04:00
believethehypeandGitHub 5e2c8de15e Merge branch 'main' into NIP90-ContentDiscovery 2024-05-16 15:03:34 +02:00
Believethehype 926a721c53 cleanup, potential fix endless loop 2024-05-16 15:02:44 +02:00
Crowdin Bot 5bc6da3bfc New Crowdin translations by GitHub Action 2024-05-16 13:00:51 +00:00
Vitor PamplonaandGitHub eae066b003 Merge pull request #860 from believethehype/NIP90-ContentDiscovery
Fix order of events, use proper event content parsing
2024-05-16 08:58:54 -04:00
Believethehype b74fa975ba Show DVM Status
Limitation: This will only show the first reply from the DVM, if mutliple status updates follow they will be ignored.
2024-05-16 14:24:13 +02:00
Believethehype 7f2b8519f3 improve parsing 2024-05-16 14:07:11 +02:00
Believethehype 51335e06f1 Fix order of Events in DVM feeds 2024-05-16 13:52:22 +02:00
VASH 0b6cd08c4a auto focus when entering the search screen 2024-05-15 21:59:21 +02:00
Vitor Pamplona 1bfe57da63 Fixes benchmark test for the new isDraft parameter 2024-05-15 10:28:01 -04:00
Vitor PamplonaandGitHub 283d52ac2f Merge pull request #856 from believethehype/NIP90-ContentDiscovery
Nip90 Content Discovery
2024-05-15 10:17:10 -04:00
Vitor Pamplona d1646761d2 Starts the process of adding a DM Relay list card to setup relays for GiftWraps 2024-05-15 10:00:54 -04:00
Believethehype dc8209c90d Update NIP90ContentDiscoveryScreen.kt 2024-05-15 15:13:16 +02:00
Believethehype 420323bcb0 adjustments and cleanups 2024-05-15 15:11:36 +02:00
Believethehype 142aca40ce add NIP90 status events, update NIP90 feeds based on request id 2024-05-15 12:06:12 +02:00
Vitor Pamplona 4fa3d60638 Speeding up the url finder in the new post edit text. 2024-05-14 20:32:48 -04:00
Vitor Pamplona a9eeb04014 Fixes alignment of the like icons after the like event. 2024-05-14 19:26:47 -04:00
Believethehype f09b00ff01 adding 5301 events to be handled 2024-05-15 00:14:15 +02:00
Believethehype ceaae398c2 Merge remote-tracking branch 'upstream/main' into NIP90-ContentDiscovery 2024-05-14 23:16:49 +02:00
Believethehype 39f87af072 remove notifications for NIP90 Content events 2024-05-14 23:09:43 +02:00
Vitor Pamplona a34a3cbc83 Fixes GirtWrap test 2024-05-14 16:34:43 -04:00
Vitor Pamplona f8afb4b783 Refactor the relay list for chat message event kind 2024-05-14 16:34:32 -04:00
Vitor Pamplona fad4248539 Moves Zap the Devs card to a better position 2024-05-14 16:33:55 -04:00
Believethehype 13a53876d0 fix sorting 2024-05-14 22:21:03 +02:00
Believethehype ea8affaebf general (unpolished) workflow works 2024-05-14 22:01:03 +02:00
Believethehype df44e172ab Update NIP90ContentDiscoveryFilter.kt 2024-05-14 21:48:59 +02:00
Believethehype 8a50b3938d send request, get results (still needs parsing) 2024-05-14 21:41:36 +02:00
Believethehype 0245c907ff add nip90 events to factory 2024-05-14 18:51:38 +02:00
Vitor Pamplona 3bdc75b1be Improves the rendering of Channels and Communities when quoted 2024-05-14 12:40:53 -04:00
Vitor Pamplona 9d02361d01 Fixes wrong display of original and forked notes 2024-05-14 12:40:29 -04:00
Vitor Pamplona 6e6a13c5bf Moves video compressing quality to medium. 2024-05-14 12:12:48 -04:00
Vitor Pamplona 1f45a63081 Makes NIP-17 the default messaging mode
Migrates code from old NIP-24 to NIP-17
2024-05-14 11:34:36 -04:00
Believethehype 11a5f4a67e Merge remote-tracking branch 'upstream/main' into NIP90-ContentDiscovery 2024-05-14 17:06:00 +02:00
Believethehype 52e79580bf attempt to add own filters and event kinds for NIP90 content related events 2024-05-14 17:03:13 +02:00
Vitor Pamplona 561b19c447 Better rendering drafts in the thread 2024-05-14 10:16:41 -04:00
believethehypeandGitHub 9b21c3c964 Merge branch 'vitorpamplona:main' into NIP90-ContentDiscovery 2024-05-14 06:29:19 +02:00
Believethehype baaa984d0d add nip90 content discovery request 2024-05-13 22:43:38 +02:00
Believethehype 2b7ef79d21 send dvm id to render list 2024-05-13 21:57:33 +02:00
Vitor Pamplona 22c96d2489 Use the default comparator instead of creating a new object every time 2024-05-13 15:40:24 -04:00
Vitor Pamplona 0c1187e4f5 Reducing the creation of new buttons when modifier objects change 2024-05-13 15:39:29 -04:00
Vitor Pamplona 7310ef175f DecimalFormats are not thread safe 2024-05-13 15:39:06 -04:00
Vitor Pamplona 53ba65cac2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-13 13:27:09 -04:00
Vitor Pamplona 58ed27dc75 Adds recommended amounts for the Zap the Devs
Block error messages from closing the Zap split payment screen.
2024-05-13 13:27:03 -04:00
Vitor Pamplona 065ba1c165 Better format zap amounts (don't show .0 if the previous numbers are large) 2024-05-13 13:25:50 -04:00
Believethehype 896d227fea Update DiscoverScreen.kt 2024-05-13 19:25:43 +02:00
Vitor Pamplona 6bac18c5df Fixes the offset position of the payment amounts on the Zap the Devs message 2024-05-13 13:24:53 -04:00
Vitor Pamplona 9ad62ef263 Reduces default zap amounts due to the change of bitcoin price 2024-05-13 13:22:58 -04:00
Believethehype fe45e188bd hide subscription dvms for now 2024-05-13 18:17:36 +02:00
Believethehype 334b948900 app meta data, show content dvms 2024-05-13 17:27:34 +02:00
Vitor PamplonaandGitHub fb9ad2b457 Merge pull request #854 from jeremyd/main
allow relay selection dialog to pick any relays
2024-05-13 10:47:17 -04:00
Vitor PamplonaandGitHub 921bb41596 Merge pull request #855 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-13 10:47:08 -04:00
Crowdin Bot ab4d01583b New Crowdin translations by GitHub Action 2024-05-13 14:44:00 +00:00
Vitor Pamplona 314531e938 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-13 10:42:05 -04:00
Vitor PamplonaandGitHub 991eed9bdf Merge pull request #845 from davotoula/843-URL-previews-mishandle-HTML-entities
843 url previews mishandle html entities
2024-05-13 10:40:33 -04:00
Believethehype 357981f266 add nip89 filter 2024-05-13 16:12:19 +02:00
Believethehype 119e9b7281 Structure for Content Discovery 2024-05-13 15:42:52 +02:00
Vitor Pamplona 8ca53e9707 Updates jackson 2024-05-10 19:47:40 -04:00
jeremyd 326e38f293 allow relay selection dialog to pick any relays (instead of just write relays) 2024-05-10 15:14:37 -07:00
Vitor Pamplona 6232e2682f Updates firebase 2024-05-09 19:10:19 -04:00
Vitor Pamplona a2363221c6 Fixes Copy Text function of DraftEvents 2024-05-07 09:58:29 -04:00
Vitor Pamplona 1eef457b4e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-06 11:32:21 -04:00
Vitor Pamplona fbd88bdeab Fixes top bar lists not updating when following communities and hashtags. 2024-05-06 11:32:14 -04:00
Vitor PamplonaandGitHub 5d25bec1c9 Merge pull request #851 from VASHvic/hide_words_check
show toast error if unable to hide words
2024-05-04 15:59:36 -04:00
VASH 201a6d4462 show toast error if unable to hide words 2024-05-04 19:38:19 +02:00
Vitor PamplonaandGitHub 1345ad3745 Merge pull request #847 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-01 13:17:55 -04:00
Crowdin Bot b5f2a2b428 New Crowdin translations by GitHub Action 2024-05-01 17:16:56 +00:00
Vitor Pamplona fe7b9b7930 Updates AGP 2024-05-01 13:09:29 -04:00
Vitor Pamplona b24d3d863d ignores new idea file 2024-05-01 13:08:02 -04:00
Vitor Pamplona 3b7252616b Updates dependencies 2024-05-01 13:07:26 -04:00
Vitor PamplonaandGitHub c76ed3bb53 Merge pull request #846 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-24 17:06:42 -04:00
Crowdin Bot cf775eebfb New Crowdin translations by GitHub Action 2024-04-24 21:05:58 +00:00
Vitor Pamplona 72018dc208 Base support for Relay lists for DMs. 2024-04-24 17:04:13 -04:00
Vitor Pamplona fef635ab39 Fixes the use of Global-active relays in the Global Feed. 2024-04-24 13:54:13 -04:00
Vitor Pamplona 7a243af45c Updates compose, kotlin and okhttp 2024-04-24 09:53:17 -04:00
David Kaspar 94af0eb220 incremented matching group due to added matching group
added nbsp entity
2024-04-23 19:59:12 +02:00
David Kaspar c0aea75c16 added test for html entity numbers to regular expression
added a few common html entities and entity numbers
2024-04-23 17:16:53 +02:00
Vitor PamplonaandGitHub 175b79b291 Merge pull request #844 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-22 16:38:35 -04:00
Crowdin Bot e5b8523bee New Crowdin translations by GitHub Action 2024-04-22 20:32:36 +00:00
Vitor Pamplona 1aecd9cf45 Minimizes costs of keeping track of the number of events received per subscription 2024-04-22 11:16:17 -04:00
Vitor Pamplona 6600a49564 Fixes the lack of refresh when adding hidden words in the Security filters 2024-04-18 19:34:15 -04:00
Vitor Pamplona cdb65640ba Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-18 17:18:29 -04:00
Vitor Pamplona 0c9e76eeaf adds content type on CardFeedView 2024-04-18 17:17:17 -04:00
Vitor PamplonaandGitHub cfdbd0a9b6 Merge pull request #842 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-18 16:35:05 -04:00
Crowdin Bot 09b8178a7c New Crowdin translations by GitHub Action 2024-04-18 20:21:04 +00:00
Vitor Pamplona 5ea793eb51 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-18 16:19:21 -04:00
Vitor Pamplona 8cf04967c3 Makes sure only one pepare is run for each video view. 2024-04-18 14:46:01 -04:00
Vitor Pamplona ef363457e8 Moves ClientController executor to a thread 2024-04-18 14:45:28 -04:00
Vitor PamplonaandGitHub e87394f3f7 Merge pull request #841 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-18 10:49:50 -04:00
Crowdin Bot 86ebfe8564 New Crowdin translations by GitHub Action 2024-04-18 14:24:22 +00:00
Vitor Pamplona a2b3cfb991 Increases timeout to Tor connections 2024-04-18 10:22:19 -04:00
Vitor PamplonaandGitHub 1b6aa621cd Merge pull request #840 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-17 17:55:02 -04:00
Crowdin Bot b7f73c6eab New Crowdin translations by GitHub Action 2024-04-17 21:50:53 +00:00
Vitor Pamplona e35fb88ff1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-17 17:49:00 -04:00
Vitor Pamplona 6ecb3c8e1f - Improves Zap efficiency for large zap splits
- Adds a queue of commands while the relay connects.
- Makes sure only one connection per URL is made when doing splits
- Removes unecessary Amber calls when decrypting private zaps
2024-04-17 17:48:53 -04:00
Vitor Pamplona ff20f0a266 Deleting unecessary logs 2024-04-17 17:47:17 -04:00
Vitor Pamplona a4cc6337f9 Don't use DM relays to find metadata 2024-04-17 17:43:40 -04:00
Vitor Pamplona 202b897029 Refactors AppScreen out of Activity. 2024-04-17 16:25:13 -04:00
Vitor PamplonaandGitHub 79c174b92e Merge pull request #839 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-17 12:23:15 -04:00
Crowdin Bot bdd3f19b2c New Crowdin translations by GitHub Action 2024-04-17 15:16:18 +00:00
Vitor Pamplona afcc775d1b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-17 11:14:10 -04:00
Vitor Pamplona 8bbf308619 refactors the use of lud16 and lud06 2024-04-17 11:14:03 -04:00
Vitor Pamplona df378937fe Adds a preview for UserNames with emojis 2024-04-17 11:12:51 -04:00
Vitor PamplonaandGitHub 02ab7a3f3f Merge pull request #838 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-16 17:16:22 -04:00
Crowdin Bot 68ba9b3b91 New Crowdin translations by GitHub Action 2024-04-16 21:14:51 +00:00
Vitor Pamplona f62833d1be Updates Firebase and Media3 dependencies 2024-04-16 17:11:52 -04:00
Vitor PamplonaandGitHub 3be246c9cc Merge pull request #835 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-12 17:07:41 -04:00
Crowdin Bot b28546b172 New Crowdin translations by GitHub Action 2024-04-12 21:05:53 +00:00
Vitor Pamplona 0fccfd7f80 clean up 2024-04-12 11:02:25 -04:00
Vitor Pamplona 3f35b57571 Improves Zap error messages to include the lnaddress of the error 2024-04-12 11:02:17 -04:00
Vitor Pamplona 32b9b6c37a Speeds up id calculations for Amber's Intent call.
Only assembles an id if necessary.
2024-04-12 11:01:32 -04:00
Vitor Pamplona 2342da114d Increases Amber signing cache. 2024-04-12 11:00:16 -04:00
Vitor Pamplona ef0d77f8eb Removes hardcoded amber packages as default 2024-04-12 10:59:59 -04:00
Vitor Pamplona 5559b69bdb Displays Zap Split error messages in sequence instead of in multiple popups. 2024-04-12 10:58:32 -04:00
Vitor Pamplona eda25b4cfe Callback uri is not necessary 2024-04-12 10:57:33 -04:00
Vitor Pamplona 9ce14e08fd Fixes a bug with signature-null in the sig of events. 2024-04-12 10:56:38 -04:00
Vitor Pamplona b046fd91cb Only logs external signer calls on debug 2024-04-11 18:30:28 -04:00
Vitor Pamplona 8c9800664f v0.86.5 2024-04-11 18:20:48 -04:00
Vitor Pamplona b90a57220d Enables Mutiny Wallet NWC 2024-04-11 18:18:54 -04:00
Vitor Pamplona d16b0f58bb Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-11 18:04:53 -04:00
Vitor Pamplona a538b66db3 Removes the use of DM relays to find events due to private inbox settings 2024-04-11 18:04:02 -04:00
Vitor Pamplona f04631b0dd Adds vertical scrolling on the Zap page for collaborators. 2024-04-11 18:01:37 -04:00
Vitor Pamplona 6e31cff99c Avoids decrypting existing Nostr events just to add the relay into the relay list. 2024-04-11 18:01:22 -04:00
Vitor Pamplona 1553640c18 Calculates hash in the IO thread from Compose's scope. 2024-04-11 16:34:34 -04:00
Vitor PamplonaandGitHub 68b8f9c82a Merge pull request #834 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-11 13:49:46 -04:00
Crowdin Bot f6cce42028 New Crowdin translations by GitHub Action 2024-04-11 17:47:21 +00:00
Vitor Pamplona 0cbddad9c0 v0.86.4 2024-04-11 13:45:27 -04:00
Vitor PamplonaandGitHub b14154e2b5 Merge pull request #832 from greenart7c3/main
Do not use tor proxy when localhost, fix proxy not being used inside ImageDownloader.kt
2024-04-10 17:30:51 -04:00
greenart7c3 c4250ccd35 fix error when not using proxy 2024-04-10 14:11:11 -03:00
greenart7c3 31516964c8 Do not use tor proxy when localhost, fix proxy not being used inside ImageDownloader.kt 2024-04-10 13:51:48 -03:00
Vitor Pamplona 4722b2a617 Fixes missing Zaps and some DMs on startup 2024-04-10 11:05:21 -04:00
Vitor Pamplona eca5b47ab0 Updates Kotlin version 2024-04-10 10:20:33 -04:00
Vitor Pamplona d38b57025c Updates compose BOM 2024-04-10 09:00:57 -04:00
Vitor Pamplona fa7aa3cf24 Updates AGP 2024-04-10 08:47:05 -04:00
Vitor Pamplona d8e9b4773b v0.86.3 2024-04-09 19:41:22 -04:00
Vitor Pamplona f9a7b13ba1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-09 19:36:49 -04:00
Vitor Pamplona ecbf0e404d Fixes missing notifications 2024-04-09 19:36:40 -04:00
Vitor PamplonaandGitHub c2f8df963a Merge pull request #831 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-09 12:23:35 -04:00
Crowdin Bot ff20960bb5 New Crowdin translations by GitHub Action 2024-04-09 16:20:30 +00:00
Vitor Pamplona 8dd1fc2077 Fixing F-droid's Markdown use 2024-04-09 12:18:14 -04:00
Vitor Pamplona bb2fb2b103 Fixes max width of hidden notes 2024-04-09 12:15:27 -04:00
Vitor Pamplona 00a9c49915 - Migrates to the new Markdown Parser.
- Adds Note previews on Markdown
- Adds Custom hashtag icons to markdown.
- Adds URL preview boxes to markdown
- Performance improvements.
2024-04-08 18:53:55 -04:00
Vitor Pamplona d2872cc8bb Refactors the url preview state 2024-04-08 18:34:10 -04:00
Vitor Pamplona d33a1ce14f Fixes clickable route not showing the user 's npub before loading the name. 2024-04-08 18:33:50 -04:00
Vitor Pamplona 31958215be Moves loading of an embed event to thread. 2024-04-08 18:33:31 -04:00
Vitor Pamplona bbbb614718 Moves added charts after NIP19 uris to being nullable. 2024-04-08 18:32:37 -04:00
Vitor Pamplona 776a23c256 Clearing the secondary button design or the encrypted nsec backup 2024-04-08 11:34:33 -04:00
Vitor Pamplona 0854bd34ff v0.86.2 2024-04-05 14:08:27 -04:00
Vitor Pamplona 1738a775ef Fixes the lack of the Comparator interface for the Deletion Index. 2024-04-05 14:05:04 -04:00
Vitor Pamplona a6953872ea Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-05 11:39:34 -04:00
Vitor Pamplona d48714456c Fixes check for Deleted Addressable events. 2024-04-05 11:34:50 -04:00
Vitor PamplonaandGitHub c25aad482b Merge pull request #829 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-05 11:23:19 -04:00
Crowdin Bot cbebfd263b New Crowdin translations by GitHub Action 2024-04-05 15:21:28 +00:00
Vitor Pamplona 89dbe82191 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-05 11:19:23 -04:00
Vitor Pamplona 7bb72d0c2d Adds a Deletion event cache. 2024-04-05 11:19:15 -04:00
Vitor PamplonaandGitHub 9be4895080 Merge pull request #828 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-05 08:19:17 -04:00
Crowdin Bot 6250db01d1 New Crowdin translations by GitHub Action 2024-04-05 12:05:47 +00:00
Vitor PamplonaandGitHub 48f9045f1b Merge pull request #825 from greenart7c3/onion_url
add ws:// if not present in .onion urls
2024-04-05 08:03:52 -04:00
Vitor PamplonaandGitHub 818ca7e39e Merge pull request #826 from greenart7c3/draft_decryption_error
fix draft decryption error
2024-04-05 08:03:39 -04:00
Vitor PamplonaandGitHub e8675b8e45 Merge pull request #827 from davotoula/update_translations
added  translations for CS, DE, SV
2024-04-05 08:03:11 -04:00
David Kaspar cef7e17447 added translations for CS, DE, SV 2024-04-05 11:38:51 +01:00
greenart7c3 6b15a0db8e fix draft decryption error 2024-04-05 05:41:03 -03:00
greenart7c3 50c5845a11 add ws:// if not present in .onion urls 2024-04-05 05:30:51 -03:00
Vitor Pamplona 1b7ba3de01 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-04 23:06:08 -04:00
Vitor Pamplona 712063f5d2 Avoids double notifications. 2024-04-04 23:05:58 -04:00
Vitor Pamplona d92f23e274 LargeCache inner map should be private 2024-04-04 23:05:35 -04:00
Vitor PamplonaandGitHub 3b7f530c0b Merge pull request #824 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-04 18:15:14 -04:00
Crowdin Bot 623a8d377c New Crowdin translations by GitHub Action 2024-04-04 22:08:10 +00:00
Vitor Pamplona 79489d0b07 v0.86.1 2024-04-04 18:06:36 -04:00
Vitor Pamplona 827512b225 Avoids circular rendering of Drafts. 2024-04-04 18:06:27 -04:00
Vitor Pamplona 6acfadeb9b Reduces cache for expandable texts. 2024-04-04 18:06:09 -04:00
Vitor Pamplona e159af2cd7 v0.86.0 2024-04-04 17:21:03 -04:00
Vitor Pamplona 89c2e9d2e0 Changes precision of Zap Splits to 1% steps 2024-04-04 17:19:03 -04:00
Vitor Pamplona 06f6ab6719 Adds button to load Zap Splits from the cited users in the text 2024-04-04 17:18:42 -04:00
Vitor Pamplona 98c48e8b6b Fixes contract violation when sorting users. 2024-04-04 16:54:14 -04:00
Vitor Pamplona 25cde455d8 removes chat bubble animation when size changes 2024-04-04 15:58:19 -04:00
Vitor Pamplona ef0fdf553c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-04 15:24:59 -04:00
Vitor Pamplona 719b950272 Fixing the use of filters that didn't discriminate the relay type setup 2024-04-04 15:24:04 -04:00
Vitor PamplonaandGitHub 2d02fad6b9 Merge pull request #823 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-04 15:16:49 -04:00
Crowdin Bot a39db5bf7b New Crowdin translations by GitHub Action 2024-04-04 19:01:46 +00:00
Vitor Pamplona 7fd37367fc refactoring of cache methods in GiftWraps 2024-04-04 14:59:49 -04:00
Vitor Pamplona e1c134830e Avoiding feed jitter when pressing the notification button twice. 2024-04-04 10:03:28 -04:00
Vitor Pamplona 621d1c7731 Migrating Refreshable feeds to the reusable box 2024-04-04 09:41:51 -04:00
Vitor Pamplona 7475143506 Continues the migration from LiveData to Flow 2024-04-04 08:56:15 -04:00
Vitor PamplonaandGitHub 2509d639bd Merge pull request #822 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-03 13:07:08 -04:00
Crowdin Bot 85dd5cf698 New Crowdin translations by GitHub Action 2024-04-03 17:02:53 +00:00
Vitor Pamplona 274ce6ad77 Fixing the spacing for channels 2024-04-03 12:26:57 -04:00
Vitor Pamplona 0e8d2fc33a adds save when closing the screen on new posts. 2024-04-03 10:20:19 -04:00
Vitor Pamplona b88723b68b Fixes wrong refactoring 2024-04-03 10:20:05 -04:00
Vitor Pamplona 638dba770d Moves the creation of Draft Notes to the IO Thread 2024-04-02 17:49:52 -04:00
Vitor Pamplona 4d7de6bc19 No need to switch to IO this early in the process 2024-04-02 16:08:59 -04:00
Vitor Pamplona fbf676bdb2 Reduces recomposition of the hash verification 2024-04-02 16:08:38 -04:00
Vitor PamplonaandGitHub 793780f02c Merge pull request #821 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-02 10:02:21 -04:00
Crowdin Bot adf31ed115 New Crowdin translations by GitHub Action 2024-04-02 13:54:47 +00:00
Vitor Pamplona 96e434bfcf Fixes the lack of following mark on chats 2024-04-02 09:52:52 -04:00
Vitor Pamplona c7563c938d Minor refactoring 2024-04-02 09:42:01 -04:00
Vitor Pamplona 4380393c5b No need to update UUID anymore. After deletion the draft can just be updated. 2024-04-02 09:23:08 -04:00
Vitor Pamplona 8125a7dabb Correctly moving the Reply line out of the renderer. 2024-04-01 18:32:36 -04:00
Vitor Pamplona e898d58239 Finishing the rendering of card notes for DMs and live chats 2024-04-01 17:07:27 -04:00
Vitor Pamplona 29a43f82e6 Fixes lack of blurhash in some videos 2024-04-01 15:26:02 -04:00
Vitor Pamplona 469b9c6acb Only changes the keep playing status if different 2024-04-01 15:25:49 -04:00
Vitor Pamplona 38d1bf9aec Updates firebase 2024-04-01 15:01:39 -04:00
Vitor Pamplona 18b57b8ac8 Reactivating hold to edit draft. 2024-04-01 14:14:58 -04:00
Vitor PamplonaandGitHub 7fc43c96d6 Merge pull request #819 from jiftechnify/meta-parser-benchmark
Add benchmark for MetaTagsParser
2024-03-29 17:46:35 -04:00
Vitor PamplonaandGitHub fc7d3a9519 Merge pull request #820 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-29 17:46:12 -04:00
Crowdin Bot 1667a78bb9 New Crowdin translations by GitHub Action 2024-03-29 21:44:07 +00:00
Vitor Pamplona 5fbd6c25d0 Fixes layout of the reply row in chats. 2024-03-29 17:41:13 -04:00
Vitor Pamplona d079d511e8 Fixes markers for DMs 2024-03-29 17:40:51 -04:00
Vitor Pamplona 6e1418cd54 - Adds a Draft Screen
- Migrating drafts to new architecture where the Draft Event is sent to the screen instead of the inner event.
- Fixes lots of deletion and indexing bugs
2024-03-29 17:38:31 -04:00
Vitor Pamplona cd84c07fcc Adds k-tag to the Deletion events. 2024-03-28 19:02:23 -04:00
jiftechnify 6eb2fbfa2f reduce creation of StringBuffers in meta tags parsing 2024-03-27 22:54:06 +09:00
jiftechnify fc6f460063 fix contact link in relay information dialog 2024-03-27 22:52:16 +09:00
jiftechnify 442cdfdf2a move MetaTagsParser to common module 2024-03-27 21:19:52 +09:00
Vitor Pamplona 539433014e Fixes Notification for Follows now showing Zaps 2024-03-26 21:05:24 -04:00
Vitor Pamplona d3f54a7082 Removes the Draft dependency for signer implementations 2024-03-26 16:56:58 -04:00
Vitor PamplonaandGitHub d3a0ae743a Merge pull request #749 from greenart7c3/main
save a draft while you are typing the post
2024-03-26 15:24:59 -04:00
Vitor Pamplona 6690d5391c Fixes controller comparison for keep playing 2024-03-26 15:06:55 -04:00
Vitor PamplonaandGitHub d61d684a27 Merge pull request #818 from jiftechnify/url-preview
Fix garbled URL preview for non UTF-8 HTML, with optimization
2024-03-26 13:40:15 -04:00
jiftechnify 4f84fad0cd remove jsoup version 2024-03-27 01:03:29 +09:00
Vitor Pamplona cd32c4db72 Makes a cache for Media Items 2024-03-26 11:56:10 -04:00
jiftechnify a71ce69cab support tags in quoted attribute value 2024-03-27 00:47:35 +09:00
Vitor Pamplona b45f9bd460 Avoids launching coroutines that were just launched. 2024-03-26 09:09:35 -04:00
Vitor Pamplona 75ac17b57d increases the time to notify to 15 minutes 2024-03-26 08:35:15 -04:00
Vitor Pamplona fa4745038f Uses the cached pool instead of the scheduled pool for translation services. 2024-03-26 08:34:45 -04:00
Vitor Pamplona 0182011487 Fixes NPE with the cached state. 2024-03-25 20:11:23 -04:00
Vitor Pamplona 37fdb8b2aa Removes unecessary parameters in the UserDisplay function 2024-03-25 17:58:54 -04:00
Vitor Pamplona 9ade18e1c1 Adds a bot field to the user info 2024-03-25 17:57:27 -04:00
Vitor Pamplona aff6588791 Removing unnecessary layouts from the top bar 2024-03-25 17:57:11 -04:00
Vitor Pamplona 4274d2ddbd Blocks previews from being too big 2024-03-25 17:54:41 -04:00
Vitor Pamplona 63600f4782 Removes time measuring log 2024-03-25 16:45:21 -04:00
Vitor Pamplona 5deb9af477 Avoids publishing with two equal hashtags. 2024-03-25 16:41:52 -04:00
Vitor Pamplona 38e701a363 Moves the Following Vector to a native composable. 2024-03-25 16:41:38 -04:00
greenart7c3andGitHub 6e6fa66c53 Merge branch 'main' into main 2024-03-25 17:34:22 -03:00
Vitor Pamplona 1f60d39cbf turning hashtag icons into programmable vectors 2024-03-25 16:02:36 -04:00
Vitor Pamplona 8ba474e79a Adjusting some of the icon sizes on the galleries 2024-03-25 14:23:36 -04:00
Vitor Pamplona 1123b3b3c1 Removes the need to observe author changes to event. 2024-03-25 13:51:08 -04:00
jiftechnify bffb9f3778 remove jsoup from dependencies 2024-03-26 02:42:15 +09:00
jiftechnify e11961695f parse HTML as little as possible 2024-03-26 02:31:06 +09:00
jiftechnify 042579ddfb remove unnecessary items from mata-tag canditates for getting URL info 2024-03-25 23:53:47 +09:00
jiftechnify d0aa7430ca optimize HTML charset detection 2024-03-25 23:53:47 +09:00
jiftechnify 3434c31487 fix garbled URL preview for non-UTF-8 HTML 2024-03-25 23:53:43 +09:00
Vitor Pamplona 7eefbee0e3 Realigning the reaction icons and texts between main feed and video feed. 2024-03-25 10:44:10 -04:00
greenart7c3 ed4d867622 save draft when toogling nip4 and nip 44 2024-03-25 07:48:30 -03:00
greenart7c3 27db2b91ab save draft when changing options from polls and classifieds 2024-03-25 07:32:31 -03:00
greenart7c3andGitHub a2316b6ed0 Merge branch 'main' into main 2024-03-25 07:08:08 -03:00
Vitor Pamplona a26b5490b2 updates compose 2024-03-24 18:53:11 -04:00
Vitor Pamplona 9990f458bf Moves Channel to LargeCache
Fixes filter for Chat Messages in discovery
2024-03-23 17:21:02 -04:00
Vitor Pamplona 0b40b6d1d8 Sorts chats by the latest message in Discovery 2024-03-23 17:19:54 -04:00
Vitor Pamplona 5a1c9f5a4a Ranks communities by the last post in each community in the discovery screen 2024-03-23 17:18:27 -04:00
Vitor Pamplona eb6d31cf2b Better filter to bring public chat messages and avoid public chat creation and metadata updates 2024-03-23 17:16:43 -04:00
Vitor Pamplona 2c5e07de87 removes the use of data classes to speed up comparisons. 2024-03-23 17:15:38 -04:00
Vitor Pamplona 8d70664cc1 Fixes the list selection on the Discovery page. 2024-03-23 17:15:19 -04:00
Vitor PamplonaandGitHub 102a34afca Merge pull request #814 from tyiu/fix-nip44-encrypt-decrypt-test
Fix NIP-44 encryptDecryptTest to decrypt with swapped private and public keys to follow NIP-44 documentation
2024-03-23 08:20:24 -04:00
Terry Yiu c843e07709 Fix Kotlin encryptDecryptTest to decrypt with swapped private and public keys to follow NIP-44 documentation 2024-03-22 23:51:57 -04:00
Vitor PamplonaandGitHub 3faa4983f0 Merge pull request #813 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-22 14:43:15 -04:00
Crowdin Bot 6f9dbbb8c3 New Crowdin translations by GitHub Action 2024-03-22 18:30:49 +00:00
greenart7c3andGitHub 62a114b981 Merge branch 'main' into main 2024-03-22 08:48:47 -03:00
Vitor Pamplona ff5612203d Fixes the width of muted messages on chat feeds. 2024-03-21 17:32:29 -04:00
Vitor Pamplona 6f0e4f1f19 Makes hidden cards full width on the discovery feed 2024-03-21 16:55:11 -04:00
Vitor Pamplona 8f5820f46d Fixes the reply finder for the reply row on compose 2024-03-21 16:54:50 -04:00
Vitor Pamplona 3086d3957d Moves from habla to njump 2024-03-21 09:27:57 -04:00
Vitor Pamplona c00319812a Reverts to make sure the logged in user's post do not appear in notifications. 2024-03-21 09:25:26 -04:00
Vitor Pamplona 67202c32d4 Migrates channel notes to LargeCache 2024-03-21 08:51:23 -04:00
Vitor Pamplona 64909bfb32 Fixes Chat preview images when no image has been set. 2024-03-21 08:50:37 -04:00
Vitor Pamplona c1756b75a7 Fixes double mention to Community headers when seeing a reply to a community post. 2024-03-21 08:42:14 -04:00
Vitor Pamplona bdf41f53fb Removes logs 2024-03-21 08:41:29 -04:00
Vitor Pamplona 3b982b8962 Fixes the centralizing of the counter after the list of participants in a live event. 2024-03-20 17:02:13 -04:00
Vitor Pamplona fd39ff24e1 Adds a space after the Channel header in the reply rows 2024-03-20 16:48:24 -04:00
Vitor Pamplona e659153730 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-20 16:48:04 -04:00
Vitor Pamplona f3c4b3255b Fixes missing context in some replies to blog posts. 2024-03-20 16:47:53 -04:00
Vitor PamplonaandGitHub f35f54166d Merge pull request #812 from greenart7c3/fix-amber-deep-sleep
add a lifeCycleOwner to register external signer on resume
2024-03-20 16:44:30 -04:00
greenart7c3 538c0493ed add a lifeCycleOwner to register external signer on resume 2024-03-20 17:20:35 -03:00
Vitor Pamplona ceca149eb7 Doesn't show edits of blog posts in the User's Profile 2024-03-20 16:03:13 -04:00
Vitor Pamplona 97cdc0bc7a Fixes layout issues of LongForm content when the image is not present. 2024-03-20 16:02:49 -04:00
Vitor Pamplona f2a8e51b20 Refactors horizontal dividers 2024-03-20 15:18:00 -04:00
greenart7c3andGitHub 8d7a3f4d5e Merge branch 'main' into main 2024-03-20 15:13:33 -03:00
greenart7c3 c087042f7d Merge branch 'main' into main 2024-03-20 15:10:32 -03:00
greenart7c3 644d2fc2bb add generic draft event to default permissions 2024-03-20 14:56:29 -03:00
Vitor Pamplona b819f24790 Reduces the space between chat bubbles. 2024-03-20 13:55:47 -04:00
greenart7c3 ea33cc77ed add edit draft in the dropdown menu and the long press popup 2024-03-20 14:50:53 -03:00
Vitor Pamplona 27fbf1c1ed Activates the chat simplified mode for the simplified setting. 2024-03-20 13:48:04 -04:00
Vitor Pamplona 3226e4e024 1. Refactors the use of dividers out of components
2. Refactors composables to load events, check hidden and check report
2024-03-20 13:47:47 -04:00
greenart7c3 090b643f43 add edit draft in the dropdown menu and the long press popup 2024-03-20 14:45:45 -03:00
Vitor Pamplona 943a4260ff Testing reduced line height 2024-03-20 11:49:12 -04:00
Vitor Pamplona 9867ac1689 Fixes videos not being able to seekTo the zero position. 2024-03-20 11:35:22 -04:00
Vitor Pamplona d26de39749 Fixes lack of zap amount refresh after zapping a note. 2024-03-20 11:35:03 -04:00
Vitor Pamplona 1072b7a5c5 Fixes following by geotags 2024-03-20 11:34:40 -04:00
greenart7c3 220ce75f19 add validations to draft notes 2024-03-20 10:25:41 -03:00
greenart7c3 bc180ae210 fix draft delete not working 2024-03-20 09:31:31 -03:00
greenart7c3 5910ef199f fix build after merge 2024-03-20 07:22:55 -03:00
greenart7c3andGitHub 499939ed68 Merge branch 'main' into main 2024-03-20 07:06:24 -03:00
Vitor Pamplona d15beb2ae5 Fixes too strict timing constraints for new posts. 2024-03-19 19:55:41 -04:00
Vitor Pamplona 87fafd9451 Reducing the amount of co-routines being launched in each LiveData update. 2024-03-19 19:30:49 -04:00
Vitor Pamplona bfbfcb6ed2 Avoids triggering an error when decoding invalid hexes. 2024-03-19 19:29:50 -04:00
Vitor Pamplona ffc0a7c6ed Avoids showing error machine-level details when paying for zaps on external wallets 2024-03-19 17:44:51 -04:00
Vitor Pamplona 77789379c0 Minimizes memory use to calculate zaps. 2024-03-19 17:44:30 -04:00
Vitor Pamplona a0a10b2cd0 Fixes some warnings 2024-03-19 17:34:26 -04:00
Vitor Pamplona d59b98089a Aligns the BOM between implementation and tests. 2024-03-19 17:33:54 -04:00
Vitor Pamplona 160e52bd91 Fixes a bug with the latest version of jackson 2024-03-19 17:33:34 -04:00
Vitor Pamplona d7e1a80465 Might be needed for those importing the AAR. 2024-03-19 17:33:09 -04:00
Vitor Pamplona a5496445d2 Simple refactoring. 2024-03-19 14:49:56 -04:00
Vitor Pamplona b75c3e3031 Refactoring caching systems for the Compose layer. 2024-03-19 14:17:15 -04:00
Vitor Pamplona e9830c61aa Removes unused addMargin param. 2024-03-19 11:50:01 -04:00
Vitor Pamplona 25f28d38d5 Only shows OTS to the respective Edit 2024-03-19 11:49:50 -04:00
Vitor Pamplona 410d6bd690 Fixes binary payloads in the search results. 2024-03-19 11:49:03 -04:00
Vitor Pamplona 99270662ee Optimizing the performance of Highlight rendering 2024-03-19 10:30:21 -04:00
Vitor Pamplona e40cd8d932 After clearing memory, only trigger liveData when it actually changed. 2024-03-19 10:07:21 -04:00
Vitor Pamplona 9858d98722 Avoids double request to show anyway when the user has hidden a post and ALSO the user's followers have reported the post. 2024-03-19 10:06:53 -04:00
Vitor Pamplona 6ab5488852 Starts with complete to avoid user shock 2024-03-19 10:06:16 -04:00
Vitor Pamplona 9651563b16 Fixing the test cases for nip96 2024-03-18 19:21:16 -04:00
Vitor Pamplona 9012bdad27 Fixes some of the testing cases 2024-03-18 18:19:05 -04:00
Vitor PamplonaandGitHub ef859c85a4 Merge pull request #810 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-18 18:09:18 -04:00
Crowdin Bot 959b2ac4e4 New Crowdin translations by GitHub Action 2024-03-18 22:08:29 +00:00
Vitor Pamplona 55a6f8829e Adds a large benchmark test for duplicate events. 2024-03-18 18:06:37 -04:00
greenart7c3 940fa2ee8d add debouncer 2024-03-18 17:47:15 -03:00
Vitor Pamplona 92d9c682f8 This should be better than launching a new thread after LaunchedEffect already lauched hers. 2024-03-18 16:38:40 -04:00
Vitor Pamplona f5a1007f88 Seeing if the performance improves when the factory becomes just another lambda 2024-03-18 16:23:00 -04:00
Vitor Pamplona d83acab84b - Solves infinite Quotation issue.
- Restructures NoteCompose for performance
- Restructures markAsRead to minimize threading cost.
2024-03-18 15:26:49 -04:00
greenart7c3 f6e5af3e98 support for dms, streams and communities 2024-03-18 16:18:12 -03:00
Vitor Pamplona ac56d02b9d Updates AGP to 8.3.1 2024-03-18 15:10:39 -04:00
Vitor PamplonaandGitHub 46a120450e Merge pull request #809 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-18 15:10:16 -04:00
Crowdin Bot 33e0107d3b New Crowdin translations by GitHub Action 2024-03-18 19:08:49 +00:00
greenart7c3 8b3e3e7af8 support Classifieds 2024-03-18 14:51:50 -03:00
greenart7c3 84faa7557e add support for polls 2024-03-18 14:37:51 -03:00
greenart7c3 f7ab925b1d fix delete on close 2024-03-18 14:21:58 -03:00
greenart7c3 204eaa4606 fix crash when loading draft from channels or lives 2024-03-18 11:16:13 -03:00
greenart7c3 1c249eed20 load post from draft 2024-03-18 10:56:36 -03:00
greenart7c3 3cc32ecd9a create a loadFromDraft method 2024-03-18 09:09:00 -03:00
greenart7c3 0a20d5484b show drafts as soon as its created 2024-03-18 08:39:55 -03:00
greenart7c3 6e4f1269dd add todo 2024-03-18 07:43:19 -03:00
greenart7c3 eba0837e52 fix draftnotes filter 2024-03-18 07:20:30 -03:00
greenart7c3andGitHub d682518ddb Merge branch 'main' into main 2024-03-18 07:07:32 -03:00
Vitor Pamplona 8c43f3492b Fixes some imports for benchmarks 2024-03-16 11:08:31 -04:00
Vitor PamplonaandGitHub 688abee205 Merge pull request #808 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-16 10:35:35 -04:00
Crowdin Bot d96ea0f8a2 New Crowdin translations by GitHub Action 2024-03-16 14:32:46 +00:00
Vitor Pamplona b19b60128f Updates to compose and jackson 2024-03-16 10:29:56 -04:00
Vitor Pamplona 78be5a9ecc Reorganizing classes in the commons lib 2024-03-15 21:28:13 -04:00
Vitor Pamplona 36d2a3e42c Isolating the LargeCache forEach method to allow quick time measurements on filters. 2024-03-15 21:14:06 -04:00
Vitor Pamplona fa7c2efaab Fixing size and alignment of the text when the live stream video is not present. 2024-03-15 21:13:15 -04:00
Vitor Pamplona 6edc634b82 Only download video, image and audio files in NIP-94 2024-03-15 19:55:14 -04:00
Vitor Pamplona 6bdf3e2625 Restructures Data Access filters and LocalCache to use a ConcurrentSkipList instead of ConcurrentHashMap 2024-03-15 19:32:43 -04:00
Vitor Pamplona 2d17200f03 Adds two additional helper methods to ATags and Classifieds. 2024-03-15 19:29:27 -04:00
Vitor Pamplona c74176684f Preparing to refactor the Relay classes. 2024-03-15 19:28:59 -04:00
Vitor Pamplona 1b9742597a Keeps cache size in 10 to account for recompositions 2024-03-15 19:26:34 -04:00
greenart7c3 f949d5624e add draft support for public chat screen 2024-03-15 13:35:54 -03:00
greenart7c3 2bc2890d08 add draft support for other event kinds 2024-03-15 12:35:14 -03:00
greenart7c3 f3f8bc1b65 show draft in the simplified view 2024-03-15 10:45:53 -03:00
greenart7c3 99e9514d6c remove drafts from shared prefs 2024-03-15 10:38:22 -03:00
greenart7c3andGitHub 8ade5b7e5f Merge branch 'main' into main 2024-03-15 09:18:38 -03:00
greenart7c3 e292affbe6 add draft in the home feed 2024-03-15 09:08:35 -03:00
Vitor Pamplona fbf4f6dd08 Small Refactoring 2024-03-14 14:09:13 -04:00
Vitor Pamplona b2508b3db5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-14 11:55:32 -04:00
Vitor Pamplona 1abdb42552 Sets default to the simplified view. 2024-03-14 11:55:24 -04:00
Vitor Pamplona 2c54ba1a92 Corrects the starting point to show reactions on messages. 2024-03-14 11:55:13 -04:00
Vitor Pamplona f941397cc4 Fixes tickmarks on dropdowns 2024-03-14 11:54:53 -04:00
Vitor PamplonaandGitHub 69e31be37f Merge pull request #807 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-14 11:14:42 -04:00
Crowdin Bot 86c328c8a7 New Crowdin translations by GitHub Action 2024-03-14 15:13:23 +00:00
Vitor Pamplona 837865a699 Adds simplified views to the video and chat feeds. 2024-03-14 11:11:38 -04:00
Vitor Pamplona 1014e29289 Fixes some contract issues when follow and names are the same. 2024-03-14 09:54:43 -04:00
Vitor PamplonaandGitHub c1c5bc2039 Merge pull request #806 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-13 18:03:06 -04:00
Crowdin Bot 2d6aab954f New Crowdin translations by GitHub Action 2024-03-13 22:00:20 +00:00
Vitor Pamplona eadb28321c Adds missing classes to support WebServer conections in the Video Playback 2024-03-13 17:58:26 -04:00
Vitor Pamplona 99850573f7 Reduces video cache from 10 to 4 videos. 2024-03-13 17:58:01 -04:00
Vitor Pamplona 45974fb09b change the isOnlineCheck to prepare for nostr nests websocket-based streaming. 2024-03-13 17:57:31 -04:00
Vitor Pamplona f2dc2ef0d0 Uses the new default factory instead of the 3 separate methods. 2024-03-13 17:57:08 -04:00
Vitor Pamplona 8641bd36c3 Fixes Scheduled Tag in LiveStreams 2024-03-13 17:28:30 -04:00
Vitor Pamplona a034d2f96e Updating Jackson 2024-03-13 14:02:50 -04:00
greenart7c3 fa5d992010 add draftevent class 2024-03-13 13:24:22 -03:00
greenart7c3andGitHub 0d47e8b823 Merge branch 'main' into main 2024-03-13 11:24:12 -03:00
Vitor Pamplona e289730be5 Fixes the cut off when having massive uncut string chars 2024-03-13 09:42:38 -04:00
greenart7c3andGitHub 91b0d5b7fc Merge branch 'main' into main 2024-03-13 07:17:52 -03:00
Vitor Pamplona 0dbd58d8d7 Fixing some of the old display name structure 2024-03-12 17:51:27 -04:00
Vitor Pamplona e094b56b72 Deleting old Settings local db 2024-03-12 17:18:06 -04:00
Vitor Pamplona efb9d9268b Avoids parsing metadata events several times due to the json parser stopping coroutines. 2024-03-12 17:16:50 -04:00
Vitor Pamplona 82c4bf89df Avoids serializing temporary fields. 2024-03-12 16:32:43 -04:00
Vitor Pamplona 4a069fa73b Simplifies if a chatroom sender intersects with the follow list. 2024-03-12 16:21:18 -04:00
Vitor Pamplona 1cda191035 Moves the play button to the profile page. 2024-03-12 15:51:02 -04:00
Vitor Pamplona e5d4b2a145 Adds a simplified version to the UI. 2024-03-12 15:50:15 -04:00
Vitor PamplonaandGitHub 6ff5787e60 Merge pull request #805 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-12 11:23:20 -04:00
Crowdin Bot ffb73b1780 New Crowdin translations by GitHub Action 2024-03-12 15:14:40 +00:00
Vitor Pamplona 9a6a857a11 Brings the ZapForwarding icon to Compose 2024-03-12 11:11:46 -04:00
Vitor Pamplona abb9edc46b Refreshing the boostrap relay list. 2024-03-12 10:29:52 -04:00
Vitor Pamplona 5d78d9b046 updates dependencies 2024-03-12 10:20:09 -04:00
Vitor Pamplona f372a256c5 formatting 2024-03-11 19:46:00 -04:00
Vitor Pamplona e2fe1538b0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-11 19:12:16 -04:00
Vitor Pamplona 7560a737cd Updated hashtag icons for performance 2024-03-11 19:10:22 -04:00
Vitor PamplonaandGitHub 24a7812432 Merge pull request #804 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-11 15:30:34 -04:00
Crowdin Bot df99c56c4a New Crowdin translations by GitHub Action 2024-03-11 18:38:52 +00:00
Vitor Pamplona e984487d9e avoids escaping errors from events inside strings 2024-03-11 13:35:56 -04:00
Vitor Pamplona afb5169ede Fitlers too many reposts out of the main feed. 2024-03-11 13:10:29 -04:00
Vitor Pamplona 64d6d6753c Centers Blank Note when post was hidden by the user. 2024-03-09 11:32:28 -05:00
Vitor Pamplona 0f69030cdf Improves Preview Utilities 2024-03-09 11:32:08 -05:00
Vitor Pamplona dd81c51fab Removes dependency of the Robohash from CryptoUtils 2024-03-09 11:30:55 -05:00
Vitor Pamplona 6cd04a7617 Fixes space after clickable user display 2024-03-08 21:51:56 -05:00
Vitor Pamplona 632bd77db3 Switches Robohash to Precompiled SVGs in order to reduce the memory burned of creating Strings with SVGs on the fly. 2024-03-08 18:41:07 -05:00
greenart7c3andGitHub 4938ba03a6 Merge branch 'main' into main 2024-03-08 12:35:23 -03:00
Vitor Pamplona e6da340879 resets the show full text flag after 20 views. 2024-03-06 15:02:59 -05:00
Vitor Pamplona c796cbd7be Fixes the fdroid flavor 2024-03-06 14:27:22 -05:00
Vitor Pamplona 2038994613 Holds the state of expanded text button between edits and translations. 2024-03-06 14:25:19 -05:00
Vitor Pamplona 5f76cdf721 Center the text of notes that couldn't be found. 2024-03-06 11:31:03 -05:00
Vitor Pamplona b8619e3b61 - User Metadata clean up upon receipt instead of in every rendering.
- Simpler/Faster UserDisplay renderings
- Removes co-routing use for Hashtags.
2024-03-06 10:32:08 -05:00
Vitor Pamplona da41fbb4c9 Updates secp256k1KmpJniAndroid 2024-03-05 19:05:03 -05:00
Vitor Pamplona 0dd553ae55 Fixes search on binary content. 2024-03-05 16:45:41 -05:00
Vitor PamplonaandGitHub 563663c131 Merge pull request #800 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-05 16:20:23 -05:00
Crowdin Bot dc3574bb6e New Crowdin translations by GitHub Action 2024-03-05 21:12:16 +00:00
Vitor Pamplona f6ffb87e20 Avoids allocating Strings for the JSON conversion when checking if a filter has changed. 2024-03-05 16:09:50 -05:00
Vitor Pamplona 3b3ca06c1c Massive refactoring to reduce the size of NoteCompose 2024-03-05 09:30:47 -05:00
Vitor Pamplona f73c9b5773 v0.85.3 2024-03-04 18:09:03 -05:00
Vitor Pamplona 5848255e72 Displaying issues and Patches in the Notification 2024-03-04 18:01:12 -05:00
Vitor Pamplona 1cf828b165 v0.85.2 2024-03-04 17:52:08 -05:00
Vitor PamplonaandGitHub 2e1f7d6587 Merge pull request #799 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-04 17:00:26 -05:00
Crowdin Bot c6977d97d3 New Crowdin translations by GitHub Action 2024-03-04 21:54:12 +00:00
Vitor Pamplona d12e886e9e Bringing GitIssues in the Notification 2024-03-04 16:52:38 -05:00
Vitor Pamplona 8299f4cfca Cleans up the fork information on new posts has been canceled or posted. 2024-03-04 16:20:59 -05:00
Vitor Pamplona 432b1e4902 Fixes forking information kept from a previous post. 2024-03-04 16:15:09 -05:00
Vitor Pamplona e9fd62dc26 Displaying correct edits on the new edit proposal 2024-03-04 15:37:29 -05:00
Vitor Pamplona f42ec3c149 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-04 15:25:43 -05:00
Vitor PamplonaandGitHub 43e1e0f23e Merge pull request #798 from greenart7c3/multiple-results-crash
fix crash parsing multiple results from amber
2024-03-04 15:17:52 -05:00
Vitor Pamplona 7bc393143c Adds link to the version notes from the drawer 2024-03-04 15:12:17 -05:00
greenart7c3 23718f51dd fix crash parsing multiple results from amber 2024-03-04 17:11:01 -03:00
Vitor Pamplona 8b2efecfbe Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-04 14:13:32 -05:00
Vitor Pamplona 9081d5a54b Adds nostr git for issue management software. 2024-03-04 14:13:23 -05:00
Vitor PamplonaandGitHub 709e3bdd3a Merge pull request #797 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-04 13:22:09 -05:00
Crowdin Bot 1f66ef717b New Crowdin translations by GitHub Action 2024-03-04 18:18:38 +00:00
Vitor Pamplona cd2b5d78a1 Don't show button to edit the post if the author of the original post is not the logged in user 2024-03-04 13:16:34 -05:00
greenart7c3 4d2c17cd1c fix draft on nip94 2024-02-28 11:16:19 -03:00
greenart7c3andGitHub 53987336c0 Merge branch 'main' into main 2024-02-28 10:59:39 -03:00
greenart7c3 cdd620987b implement reply draft 2024-01-24 09:55:31 -03:00
greenart7c3 2c086f76e2 open the post screen after editing the text 2024-01-24 08:40:55 -03:00
greenart7c3 99965ecd2d add an edit draft in the drawer 2024-01-24 08:22:35 -03:00
greenart7c3 ba7c59fdd5 draft was not saving in some places 2024-01-24 08:22:11 -03:00
greenart7c3 76a93f84c3 fix default value for the draft note 2024-01-24 08:21:04 -03:00
greenart7c3 26a1624399 save a draft while you are typing the post 2024-01-22 09:02:44 -03:00
521 changed files with 49340 additions and 18026 deletions
+2
View File
@@ -7,9 +7,11 @@
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
/.idea/deploymentTargetSelector.xml
/.idea/appInsightsSettings.xml
/.idea/ktlint-plugin.xml
/.idea/ktfmt.xml
/.idea/studiobot.xml
.DS_Store
/build
/captures
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.22" />
<option name="version" value="1.9.24" />
</component>
</project>
+27 -14
View File
@@ -43,7 +43,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] OpenTimestamps Attestations (NIP-03)
- [x] Private Messages (NIP-04)
- [x] DNS Address (NIP-05)
- [ ] Mnemonic seed phrase (NIP-06)
- [x] Mnemonic seed phrase (NIP-06)
- [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09)
@@ -55,6 +55,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Events with a Subject (NIP-14)
- [ ] Marketplace (NIP-15)
- [x] Event Treatment (NIP-16)
- [x] Private Direct Messages (NIP-17)
- [x] Image/Video/Url/LnInvoice Previews
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
- [x] Bech Encoding support (NIP-19)
@@ -66,17 +67,20 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Delegated Event Signing (NIP-26, Will not implement)
- [x] Text Note References (NIP-27)
- [x] Public Chats (NIP-28)
- [ ] Relay-based Groups (NIP-29)
- [x] Custom Emoji (NIP-30)
- [x] Event kind summaries (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Parameterized Replaceable Events (NIP-33)
- [x] Git Stuff (NIP-34/Draft)
- [x] Git Stuff (NIP-34)
- [ ] Torrents (NIP-35)
- [x] Sensitive Content (NIP-36)
- [x] Note Edits (NIP-37/Draft)
- [x] Edits (NIP-37/Draft)
- [x] User Status Event (NIP-38)
- [x] External Identities (NIP-39)
- [x] Expiration Support (NIP-40)
- [x] Relay Authentication (NIP-42)
- [x] Versioned Encrypted Payloads (NIP-44)
- [ ] Event Counts (NIP-45, Will not implement)
- [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47)
@@ -86,31 +90,32 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Wiki (NIP-54)
- [x] Inline Metadata (NIP-55 - Draft)
- [x] Reporting (NIP-56)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Private Zaps
- [x] Zap Splits (NIP-57)
- [x] Badges (NIP-58)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Zapraiser (NIP-TBD)
- [x] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65)
- [x] Polls (NIP-69)
- [x] Video Events (NIP-71)
- [x] Moderated Communities (NIP-72)
- [ ] Zap Goals (NIP-75)
- [ ] Arbitrary Custom App Data (NIP-78)
- [x] Highlights (NIP-84)
- [x] Notify Request (NIP-88/Draft)
- [x] Recommended Application Handlers (NIP-89)
- [ ] Data Vending Machine (NIP-90)
- [x] Data Vending Machine (NIP-90)
- [x] Inline Metadata (NIP-92)
- [x] Verifiable file URLs (NIP-94)
- [x] Binary Blobs (NIP-95)
- [x] HTTP File Storage Integration (NIP-96 Draft)
- [x] Binary Blobs (NIP-95/Draft)
- [x] HTTP File Storage Integration (NIP-96)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Private Messages and Small Groups (NIP-24/Draft)
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
- [x] Audio Tracks (zapstr.live) (kind:31337)
- [x] Push Notifications (Google and Unified Push)
- [x] In-Device Automatic Translations
@@ -120,9 +125,15 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] De-googled F-Droid flavor
- [x] Multiple Accounts
- [x] Markdown Support
- [x] FHIR Payloads (kind:82)
- [ ] Decentralized Wiki (kind:30818)
- [ ] Embed events
- [x] Medical Data (NIP-xx/Draft)
- [x] Embed events (NIP-xx/Draft)
- [x] Draft Events (NIP-xx/Draft)
- [ ] Event Sets (NIP-xx/Draft)
- [ ] Topical Notes (NIP-xx/Draft)
- [ ] Relationship Status (NIP-xx/Draft)
- [ ] Signed Filters (NIP-xx/Draft)
- [ ] Key Migration (NIP-xx/Draft)
- [ ] Time-based Sync (NIP-xx/Draft)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Workspaces
@@ -241,12 +252,14 @@ dependencyResolutionManagement {
Add the dependency
```gradle
implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3')
implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1')
```
## Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are very welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)
[GitHub issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
+10 -10
View File
@@ -12,9 +12,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 360
versionName "0.85.1"
buildConfigField "String", "RELEASE_NOTES_ID", "\"d8da33fd13d129d86c53564aedefafbe3716f007c520431be4a8e488d3925afb\""
versionCode 371
versionName "0.87.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"863124b6592359494396c22b917f9d251ad12ac31b09ea29e0265e3c088ce9f8\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -143,7 +143,7 @@ android {
composeOptions {
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.8"
kotlinCompilerExtensionVersion "1.5.14"
}
packagingOptions {
resources {
@@ -151,7 +151,6 @@ android {
}
}
lint {
disable 'MissingTranslation'
}
@@ -162,13 +161,13 @@ android {
}
dependencies {
implementation platform(libs.androidx.compose.bom)
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.ui
implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview
@@ -205,9 +204,6 @@ dependencies {
// Websockets API
implementation libs.okhttp
// HTML Parsing for Link Preview
implementation libs.jsoup
// Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx
@@ -282,9 +278,13 @@ dependencies {
testImplementation libs.junit
testImplementation libs.mockk
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.junit.ktx
androidTestImplementation libs.androidx.espresso.core
debugImplementation platform(libs.androidx.compose.bom)
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst
import android.graphics.Bitmap
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.FileHeader
@@ -29,70 +31,17 @@ import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.KeyPair
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
import java.io.ByteArrayOutputStream
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val contentType = "image/gif"
val image =
"R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzW" +
"lwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2c" +
"cMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjA" +
"J8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8A" +
"AF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMu" +
"QeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSH" +
"pzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGR" +
"s/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78A" +
"AAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMi" +
"wocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7G" +
"nwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euT" +
"eJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dt" +
"GCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWl" +
"Mc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPe" +
"iUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYI" +
"m4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZ" +
"cNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9" +
"aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3A" +
"DTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kV" +
"MyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDG" +
"qCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMW" +
"ZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bD" +
"GdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB77" +
"6aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJH" +
"gxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiA" +
"FB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPA" +
"gCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHg" +
"rhGSQJxCS+0pCZbEhAAOw=="
val contentTypePng = "image/png"
val imagePng =
"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3" +
"/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXd" +
"tdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEn" +
"xBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nH" +
"L0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2ud" +
"LFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8" +
"Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoeP" +
"PQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/" +
"9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlw" +
"jlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN97" +
"9jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC1" +
"7MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2r" +
"eNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+h" +
"uNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66Pfyu" +
"Rj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMT" +
"hZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo =
Nip96Retriever()
@@ -100,7 +49,15 @@ class ImageUploadTesting {
server.baseUrl,
)
val bytes = Base64.getDecoder().decode(imagePng)
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
}
}
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
val inputStream = bytes.inputStream()
val account = Account(KeyPair())
@@ -110,7 +67,7 @@ class ImageUploadTesting {
.uploadImage(
inputStream,
bytes.size.toLong(),
contentTypePng,
"image/png",
alt = null,
sensitiveContent = null,
serverInfo,
@@ -124,31 +81,31 @@ class ImageUploadTesting {
val contentType = result.tags!!.first { it[0] == "m" }.get(1)
val ox = result.tags!!.first { it[0] == "ox" }.get(1)
Assert.assertTrue(url.startsWith("http"))
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
?: run {
fail("Should not be null")
fail("${server.name}: Should not be null")
return
}
FileHeader.prepare(
imageData,
contentTypePng,
"image/png",
null,
onReady = {
if (dim != null) {
assertEquals(dim, it.dim)
// assertEquals("${server.name}: Invalid dimensions", it.dim, dim)
}
if (size != null) {
assertEquals(size, it.size.toString())
// assertEquals("${server.name}: Invalid size", it.size.toString(), size)
}
if (hash != null) {
assertEquals(hash, it.hash)
assertEquals("${server.name}: Invalid hash", it.hash, hash)
}
},
onError = { fail("It should not fail") },
onError = { fail("${server.name}: It should not fail") },
)
// delay(1000)
@@ -156,6 +113,14 @@ class ImageUploadTesting {
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
}
@Test
fun runTestOnDefaultServers() =
runBlocking {
Nip96MediaServers.DEFAULT.forEach {
testBase(it)
}
}
@Test()
fun testNostrCheck() =
runBlocking {
@@ -163,12 +128,14 @@ class ImageUploadTesting {
}
@Test()
@Ignore("Not Working anymore")
fun testNostrage() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
}
@Test()
@Ignore("Not Working anymore")
fun testSove() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
@@ -181,6 +148,7 @@ class ImageUploadTesting {
}
@Test()
@Ignore("Not Working anymore")
fun testSovbit() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
@@ -191,4 +159,17 @@ class ImageUploadTesting {
runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
}
@Test()
fun testNostrPic() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrOnch() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services"))
}
}
@@ -56,7 +56,7 @@ object PushDistributorHandler : PushDistributorActions {
}
override fun getSavedDistributor(): String {
return unifiedPush.getDistributor(appContext)
return unifiedPush.getSavedDistributor(appContext) ?: ""
}
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
@@ -47,9 +47,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.Material3RichText
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
@@ -101,12 +103,18 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
onDismissRequest = { distributorPresent = true },
title = { Text(stringResource(R.string.push_server_install_app)) },
text = {
Material3RichText(
val content = stringResource(R.string.push_server_install_app_description)
val astNode =
remember {
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content)
}
RichText(
style = RichTextStyle().resolveDefaults(),
renderer = null,
) {
Markdown(
content = stringResource(R.string.push_server_install_app_description),
)
BasicMarkdown(astNode)
}
},
confirmButton = {
@@ -31,17 +31,21 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists
fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier = Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) = ExpandableRichTextViewer(
content,
canPreview,
quotesLeft,
modifier,
tags,
backgroundColor,
id,
accountViewModel,
nav,
)
@@ -27,18 +27,12 @@ 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.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -83,6 +77,7 @@ private object PrefKeys {
const val NOSTR_PUBKEY = "nostr_pubkey"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
@@ -97,7 +92,7 @@ private object PrefKeys {
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
@@ -290,6 +285,7 @@ object LocalPreferences {
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)
putString(
PrefKeys.LANGUAGE_PREFS,
Event.mapper.writeValueAsString(account.languagePreferences),
@@ -324,7 +320,7 @@ object LocalPreferences {
Event.mapper.writeValueAsString(account.backupContactList),
)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
putBoolean(PrefKeys.HIDE_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)
@@ -354,15 +350,6 @@ object LocalPreferences {
return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
}
suspend fun migrateOldSharedSettings(): Settings? {
val prefs = encryptedPreferences()
loadOldSharedSettings(prefs)?.let {
saveSharedSettings(it, prefs)
return it
}
return null
}
suspend fun saveSharedSettings(
sharedSettings: Settings,
prefs: SharedPreferences = encryptedPreferences(),
@@ -390,67 +377,6 @@ object LocalPreferences {
}
}
@Deprecated("Turned into a single JSON object")
suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
with(prefs) {
if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
return null
}
val automaticallyShowImages =
if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyStartPlayback =
if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyShowUrlPreview =
if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyHideNavigationBars =
if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
} else {
BooleanType.ALWAYS
}
val automaticallyShowProfilePictures =
if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
} else {
ConnectivityType.ALWAYS
}
val themeType =
if (contains(PrefKeys.THEME)) {
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
} else {
ThemeType.SYSTEM
}
return Settings(
themeType,
getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null },
automaticallyShowImages,
automaticallyStartPlayback,
automaticallyShowUrlPreview,
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
false,
false,
)
}
}
val mutex = Mutex()
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? =
@@ -486,6 +412,7 @@ object LocalPreferences {
?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
@@ -602,7 +529,7 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
@@ -653,6 +580,7 @@ object LocalPreferences {
keyPair = keyPair,
signer = signer,
localRelays = localRelays,
localRelayServers = localRelayServers,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
@@ -667,7 +595,7 @@ object LocalPreferences {
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP24WarningDialog = hideNIP24WarningDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
@@ -58,6 +58,7 @@ 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
@Stable
@@ -104,7 +105,7 @@ class ServiceManager {
}
if (myAccount != null) {
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
val relaySet = myAccount.connectToRelays.value
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
@@ -112,10 +113,10 @@ class ServiceManager {
collectorJob = null
collectorJob =
scope.launch {
myAccount.userProfile().flow().relays.stateFlow.collect {
myAccount.connectToRelaysFlow.collectLatest {
delay(500)
if (isStarted) {
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Client.reconnect(newRelaySet, onlyIfChanged = true)
Client.reconnect(it, onlyIfChanged = true)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
@@ -33,7 +35,6 @@ import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
@Stable
class PublicChatChannel(idHex: String) : Channel(idHex) {
@@ -107,10 +108,9 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
@Stable
abstract class Channel(val idHex: String) {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>()
val notes = LargeCache<HexKey, Note>()
var lastNoteCreatedAt: Long = 0
open fun id() = Hex.decode(idHex)
@@ -131,7 +131,7 @@ abstract class Channel(val idHex: String) {
}
open fun profilePicture(): String? {
return creator?.profilePicture()
return creator?.info?.banner
}
open fun updateChannelInfo(
@@ -145,7 +145,11 @@ abstract class Channel(val idHex: String) {
}
fun addNote(note: Note) {
notes[note.idHex] = note
notes.put(note.idHex, note)
if ((note.createdAt() ?: 0) > lastNoteCreatedAt) {
lastNoteCreatedAt = note.createdAt() ?: 0
}
}
fun removeNote(note: Note) {
@@ -163,18 +167,18 @@ abstract class Channel(val idHex: String) {
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important =
notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.take(1000)
notes.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == false
}
.sortedWith(DefaultFeedOrder)
.take(500)
.toSet()
val toBeRemoved = notes.values.filter { it !in important }.toSet()
val toBeRemoved = notes.filter { key, it -> it !in important }
toBeRemoved.forEach { notes.remove(it.idHex) }
return toBeRemoved
return toBeRemoved.toSet()
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class Chatroom() {
var authors: Set<User> = setOf()
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@@ -38,6 +39,12 @@ class Chatroom() {
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
msg.author?.let { author ->
if (author !in authors) {
authors += author
}
}
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
@@ -51,8 +58,8 @@ class Chatroom() {
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
if (msg in roomMessages) {
roomMessages = roomMessages - msg
roomMessages
.filter { it.event?.subject() != null }
@@ -66,7 +73,7 @@ class Chatroom() {
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
return authors.any { it.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
@@ -21,162 +21,91 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.Btc
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.commons.hashtags.Mate
import com.vitorpamplona.amethyst.commons.hashtags.Nostr
import com.vitorpamplona.amethyst.commons.hashtags.Plebs
import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.events.EmptyTagList
fun checkForHashtagWithIcon(
tag: String,
primary: Color,
): HashtagIcon? {
@Preview
@Composable
fun RenderHashTagIcons() {
val nav: (String) -> Unit = {}
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate",
EmptyTagList,
) { word, state ->
when (word) {
is HashTagSegment -> HashTag(word, nav)
is RegularTextSegment -> Text(word.segmentText)
}
}
}
}
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
"₿itcoin",
"bitcoin",
"btc",
"timechain",
"bitcoiner",
"bitcoiners",
->
HashtagIcon(
R.drawable.ht_btc,
"Bitcoin",
Color.Unspecified,
Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp),
)
"nostr",
"nostrich",
"nostriches",
"thenostr",
->
HashtagIcon(
R.drawable.ht_nostr,
"Nostr",
Color.Unspecified,
Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp),
)
"lightning",
"lightningnetwork",
->
HashtagIcon(
R.drawable.ht_lightning,
"Lightning",
Color.Unspecified,
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
)
"zap",
"zaps",
"zapper",
"zappers",
"zapping",
"zapped",
"zapathon",
"zapraiser",
"zaplife",
"zapchain",
->
HashtagIcon(
R.drawable.zap,
"Zap",
Color.Unspecified,
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
)
"amethyst" ->
HashtagIcon(
R.drawable.amethyst,
"Amethyst",
Color.Unspecified,
Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp),
)
"onyx" ->
HashtagIcon(
R.drawable.black_heart,
"Onyx",
Color.Unspecified,
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
)
"cashu",
"ecash",
"nut",
"nuts",
"deeznuts",
->
HashtagIcon(
R.drawable.cashu,
"Cashu",
Color.Unspecified,
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
)
"plebs",
"pleb",
"plebchain",
->
HashtagIcon(
R.drawable.plebs,
"Pleb",
Color.Unspecified,
Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp),
)
"coffee",
"coffeechain",
"cafe",
->
HashtagIcon(
R.drawable.coffee,
"Coffee",
Color.Unspecified,
Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp),
)
"skullofsatoshi" ->
HashtagIcon(
R.drawable.skull,
"SkullofSatoshi",
Color.Unspecified,
Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp),
)
"grownostr",
"gardening",
"garden",
->
HashtagIcon(
R.drawable.grownostr,
"GrowNostr",
Color.Unspecified,
Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp),
)
"footstr" ->
HashtagIcon(
R.drawable.footstr,
"Footstr",
Color.Unspecified,
Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp),
)
"tunestr",
"music",
"nowplaying",
->
HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
"weed",
"weedstr",
"420",
"cannabis",
"marijuana",
->
HashtagIcon(
R.drawable.weed,
"Weed",
Color.Unspecified,
Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp),
)
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
"lightning", "lightningnetwork" -> lightning
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> zap
"amethyst" -> amethyst
"cashu", "ecash", "nut", "nuts", "deeznuts" -> cashu
"plebs", "pleb", "plebchain" -> plebs
"coffee", "coffeechain", "cafe" -> coffee
"skullofsatoshi" -> skull
"grownostr", "gardening", "garden" -> growstr
"footstr" -> footstr
"tunestr", "music", "nowplaying" -> tunestr
"mate", "matechain", "matestr" -> matestr
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
else -> null
}
}
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val lightning = HashtagIcon(CustomHashTagIcons.Lightning, "Lightning", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val zap = HashtagIcon(CustomHashTagIcons.Zap, "Zap", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val amethyst = HashtagIcon(CustomHashTagIcons.Amethyst, "Amethyst", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val cashu = HashtagIcon(CustomHashTagIcons.Cashu, "Cashu", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val plebs = HashtagIcon(CustomHashTagIcons.Plebs, "Pleb", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(start = 3.dp, bottom = 1.dp, top = 1.dp))
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
@Immutable
class HashtagIcon(
val icon: Int,
val icon: ImageVector,
val description: String,
val color: Color,
val modifier: Modifier,
val modifier: Modifier = Modifier,
)
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.actions.relays.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
@@ -47,6 +48,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
@@ -70,9 +72,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
@Stable
@@ -97,6 +96,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
fun dTag(): String? {
return (event as? AddressableEvent)?.dTag()
}
override fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
deletionAddressables: Set<ATag>,
): Boolean {
val thisEvent = event
return deletionAddressables.contains(address) || (thisEvent != null && deletionEvents.contains(thisEvent.id()))
}
}
@Stable
@@ -145,7 +152,7 @@ open class Note(val idHex: String) {
Nip19Bech32.createNEvent(
host.id,
host.pubKey,
host.kind(),
host.kind,
relays.firstOrNull()?.url,
)
} else {
@@ -171,7 +178,8 @@ open class Note(val idHex: String) {
event is LiveActivitiesEvent
) {
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
} else {
@@ -183,6 +191,8 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun isDraft() = event is DraftEvent
fun loadEvent(
event: Event,
author: User,
@@ -198,96 +208,6 @@ open class Note(val idHex: String) {
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long,
): LevelSignature {
val replyTo = replyTo
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
createdAt = createdAt(),
author = author,
)
}
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: it
.replyLevelSignature(
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
)
.apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == author && createdAt() != null) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) +
idHex.substring(0, 8)
} else if (author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
} else if (author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = createdAt(),
author = author,
)
cachedSignatures[this] = mySignature
return mySignature
}
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
@@ -310,6 +230,12 @@ open class Note(val idHex: String) {
}
fun removeAllChildNotes(): List<Note> {
val repliesChanged = replies.isNotEmpty()
val reactionsChanged = reactions.isNotEmpty()
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty()
val boostsChanged = boosts.isNotEmpty()
val reportsChanged = reports.isNotEmpty()
val toBeRemoved =
replies +
reactions.values.flatten() +
@@ -330,11 +256,11 @@ open class Note(val idHex: String) {
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
liveSet?.innerReplies?.invalidateData()
liveSet?.innerReactions?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
liveSet?.innerReports?.invalidateData()
liveSet?.innerZaps?.invalidateData()
if (repliesChanged) liveSet?.innerReplies?.invalidateData()
if (reactionsChanged) liveSet?.innerReactions?.invalidateData()
if (boostsChanged) liveSet?.innerBoosts?.invalidateData()
if (reportsChanged) liveSet?.innerReports?.invalidateData()
if (zapsChanged) liveSet?.innerZaps?.invalidateData()
return toBeRemoved
}
@@ -529,7 +455,7 @@ open class Note(val idHex: String) {
option: Int?,
user: User,
account: Account,
remainingZapEvents: List<Pair<Note, Note?>>,
remainingZapEvents: Map<Note, Note?>,
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapEvents.isEmpty()) {
@@ -537,8 +463,8 @@ open class Note(val idHex: String) {
}
remainingZapEvents.forEach { next ->
val zapRequest = next.first.event as LnZapRequestEvent
val zapEvent = next.second?.event as? LnZapEvent
val zapRequest = next.key.event as LnZapRequestEvent
val zapEvent = next.value?.event as? LnZapEvent
if (!zapRequest.isPrivateZap()) {
// public events
@@ -582,7 +508,7 @@ open class Note(val idHex: String) {
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
isZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
isZappedByCalculation(null, user, account, zaps, onWasZappedByAuthor)
if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
}
@@ -594,7 +520,7 @@ open class Note(val idHex: String) {
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
isZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
isZappedByCalculation(option, user, account, zaps, onWasZappedByAuthor)
}
fun getReactionBy(user: User): String? {
@@ -921,6 +847,14 @@ open class Note(val idHex: String) {
createOrDestroyFlowSync(false)
}
}
open fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
deletionAddressables: Set<ATag>,
): Boolean {
val thisEvent = event
return deletionEvents.contains(idHex) || (thisEvent is AddressableEvent && deletionAddressables.contains(thisEvent.address()))
}
}
@Stable
@@ -958,8 +892,6 @@ class NoteLiveSet(u: Note) {
val relays = innerRelays.map { it }
val zaps = innerZaps.map { it }
val authorChanges = innerMetadata.map { it.note.author }.distinctUntilChanged()
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
val hasReactions =
@@ -997,7 +929,6 @@ class NoteLiveSet(u: Note) {
reports.hasObservers() ||
relays.hasObservers() ||
zaps.hasObservers() ||
authorChanges.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
@@ -1097,8 +1028,7 @@ object RelayBriefInfoCache {
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String =
url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val displayUrl: String = RelayUrlFormatter.displayUrl(url).intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern(),
)
@@ -96,7 +96,7 @@ class ParticipantListBuilder {
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
@@ -34,6 +34,7 @@ data class Settings(
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
val dontShowPushNotificationSelector: Boolean = false,
val dontAskForNotificationPermissions: Boolean = false,
val featureSet: FeatureSetType = FeatureSetType.COMPLETE,
)
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
@@ -59,6 +60,11 @@ enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val res
NEVER(false, 2, R.string.connectivity_type_never),
}
enum class FeatureSetType(val screenCode: Int, val resourceId: Int) {
COMPLETE(0, R.string.ui_feature_set_type_complete),
SIMPLIFIED(1, R.string.ui_feature_set_type_simplified),
}
fun parseConnectivityType(code: Boolean?): ConnectivityType {
return when (code) {
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
@@ -81,6 +87,16 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
}
}
fun parseFeatureSetType(screenCode: Int): FeatureSetType {
return when (screenCode) {
FeatureSetType.COMPLETE.screenCode -> FeatureSetType.COMPLETE
FeatureSetType.SIMPLIFIED.screenCode -> FeatureSetType.SIMPLIFIED
else -> {
FeatureSetType.COMPLETE
}
}
}
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never),
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue
@@ -78,7 +80,7 @@ class ThreadAssembler {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()
val thread = OnlyLatestVersionSet()
val threadRoot = searchRoot(note, thread) ?: note
@@ -87,7 +89,7 @@ class ThreadAssembler {
// did not added them.
note.replies.forEach { loadDown(it, thread) }
thread.toSet()
thread
} else {
setOf(note)
}
@@ -109,3 +111,87 @@ class ThreadAssembler {
}
}
}
class OnlyLatestVersionSet : MutableSet<Note> {
val map = hashMapOf<ATag, Long>()
val set = hashSetOf<Note>()
override fun add(element: Note): Boolean {
val loadedCreatedAt = element.createdAt()
val noteEvent = element.event
return if (element is AddressableNote && loadedCreatedAt != null) {
innerAdd(element.address, element, loadedCreatedAt)
} else if (noteEvent is AddressableEvent && loadedCreatedAt != null) {
innerAdd(noteEvent.address(), element, loadedCreatedAt)
} else {
set.add(element)
}
}
private fun innerAdd(
address: ATag,
element: Note,
loadedCreatedAt: Long,
): Boolean {
val existing = map.get(address)
return if (existing == null) {
map.put(address, loadedCreatedAt)
set.add(element)
} else {
if (loadedCreatedAt > existing) {
map.put(address, loadedCreatedAt)
set.add(element)
} else {
false
}
}
}
override fun addAll(elements: Collection<Note>): Boolean {
return elements.map { add(it) }.any()
}
override val size: Int
get() = set.size
override fun clear() {
set.clear()
map.clear()
}
override fun isEmpty(): Boolean {
return set.isEmpty()
}
override fun containsAll(elements: Collection<Note>): Boolean {
return set.containsAll(elements)
}
override fun contains(element: Note): Boolean {
return set.contains(element)
}
override fun iterator(): MutableIterator<Note> {
return set.iterator()
}
override fun retainAll(elements: Collection<Note>): Boolean {
return set.retainAll(elements)
}
override fun removeAll(elements: Collection<Note>): Boolean {
return elements.map { remove(it) }.any()
}
override fun remove(element: Note): Boolean {
element.address()?.let {
map.remove(it)
}
(element.event as? AddressableEvent)?.address()?.let {
map.remove(it)
}
return set.remove(element)
}
}
@@ -0,0 +1,125 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
object ThreadLevelCalculator {
val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
private fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
}
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
note: Note,
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long,
): LevelSignature {
val replyTo = note.replyTo
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";",
createdAt = note.createdAt(),
author = note.author,
)
}
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: replyLevelSignature(
it,
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == note.author && note.createdAt() != null) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) +
note.idHex.substring(0, 8)
} else if (note.author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies
} else if (note.author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = note.createdAt(),
author = note.author,
)
cachedSignatures[note] = mySignature
return mySignature
}
fun replyLevel(
note: Note,
cachedLevels: MutableMap<Note, Int> = mutableMapOf(),
): Int {
val replyTo = note.replyTo
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: replyLevel(it, cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
}
@@ -26,8 +26,6 @@ import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Stable
object UrlCachedPreviewer {
@@ -37,46 +35,44 @@ object UrlCachedPreviewer {
suspend fun previewInfo(
url: String,
onReady: suspend (UrlPreviewState) -> Unit,
) = withContext(Dispatchers.IO) {
) {
cache[url]?.let {
onReady(it)
return@withContext
return
}
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override suspend fun onComplete(urlInfo: UrlInfoItem) =
withContext(Dispatchers.IO) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return@withContext
}
}
val state =
if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
cache.put(url, state)
onReady(state)
}
override suspend fun onFailed(throwable: Throwable) =
withContext(Dispatchers.IO) {
cache[url]?.let {
override suspend fun onComplete(urlInfo: UrlInfoItem) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return@withContext
return
}
}
val state =
if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
cache.put(url, state)
onReady(state)
}
override suspend fun onFailed(throwable: Throwable) {
cache[url]?.let {
onReady(it)
return
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
},
)
.fetchUrlPreview()
@@ -52,6 +52,7 @@ import java.math.BigDecimal
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
var latestMetadata: MetadataEvent? = null
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
@@ -80,7 +81,7 @@ class User(val pubkeyHex: String) {
override fun toString(): String = pubkeyHex
fun toBestShortFirstName(): String {
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
val fullName = toBestDisplayName()
val names = fullName.split(' ')
@@ -96,23 +97,14 @@ class User(val pubkeyHex: String) {
}
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
}
fun bestUsername(): String? {
return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return info?.displayName?.ifBlank { null }
return info?.bestName() ?: pubkeyDisplayHex()
}
fun nip05(): String? {
return info?.nip05?.ifBlank { null }
return info?.nip05
}
fun profilePicture(): String? {
if (info?.picture.isNullOrBlank()) info?.picture = null
return info?.picture
}
@@ -135,6 +127,7 @@ class User(val pubkeyHex: String) {
// Update following of the current user
liveSet?.innerFollows?.invalidateData()
flowSet?.follows?.invalidateData()
// Update Followers of the past user list
// Update Followers of the new contact list
@@ -285,6 +278,18 @@ class User(val pubkeyHex: String) {
}
}
fun removeMessage(
room: ChatroomKey,
msg: Note,
) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
}
}
fun addRelayBeingUsed(
relay: Relay,
eventTime: Long,
@@ -307,9 +312,8 @@ class User(val pubkeyHex: String) {
latestMetadata: MetadataEvent,
) {
info = newUserInfo
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
info?.tags = latestMetadata.tags.toImmutableListOfLists()
info?.cleanBlankNames()
if (newUserInfo.lud16.isNullOrBlank()) {
info?.lud06?.let {
@@ -363,7 +367,7 @@ class User(val pubkeyHex: String) {
}
suspend fun transientFollowerCount(): Int {
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
@@ -387,13 +391,13 @@ class User(val pubkeyHex: String) {
}
suspend fun cachedFollowerCount(): Int {
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
return messagesToUser.authors.any { this == it }
}
fun hasReport(
@@ -471,14 +475,16 @@ class User(val pubkeyHex: String) {
@Stable
class UserFlowSet(u: User) {
// Observers line up here.
val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean {
return relays.stateFlow.subscriptionCount.value > 0
return relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
}
fun destroy() {
relays.destroy()
follows.destroy()
}
}
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
object CreatedAtComparator : Comparator<Note> {
override fun compare(
first: Note?,
second: Note?,
): Int {
val firstEvent = first?.event
val secondEvent = second?.event
return if (firstEvent == null && secondEvent == null) {
0
} else if (firstEvent == null) {
1
} else if (secondEvent == null) {
-1
} else {
firstEvent.createdAt().compareTo(secondEvent.createdAt())
}
}
}
object CreatedAtComparatorAddresses : Comparator<AddressableNote> {
override fun compare(
first: AddressableNote?,
second: AddressableNote?,
): Int {
val firstEvent = first?.event
val secondEvent = second?.event
return if (firstEvent == null && secondEvent == null) {
0
} else if (firstEvent == null) {
1
} else if (secondEvent == null) {
-1
} else {
firstEvent.createdAt().compareTo(secondEvent.createdAt())
}
}
}
@@ -0,0 +1,72 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindAndAuthor<T : Event>(private val kind: Int, private val pubkey: String) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
fun matches(event: T) = event.kind == kind && event.pubKey == pubkey
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
}
}
}
fun canDelete(): Boolean {
return _latest.subscriptionCount.value == 0
}
suspend fun init() {
val latestNote =
if ((kind in 10000..19999) || (kind in 30000..39999)) {
LocalCache.addressables.maxOrNullOf(
filter = { idHex: String, note: AddressableNote ->
note.event?.let {
it.kind() == kind && it.pubKey() == pubkey
} == true
},
comparator = CreatedAtComparatorAddresses,
)?.event as? T
} else {
LocalCache.notes.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind() == kind && it.pubKey() == pubkey
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
}
_latest.tryEmit(latestNote)
}
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindWithETag<T : Event>(private val kind: Int, private val eTag: String) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
fun matches(event: T) = event.kind == kind && event.isTaggedEvent(eTag)
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
}
}
}
fun canDelete(): Boolean {
return _latest.subscriptionCount.value == 0
}
suspend fun init() {
val latestNote =
LocalCache.notes.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind() == kind && it.isTaggedEvent(eTag)
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
_latest.tryEmit(latestNote)
}
}
@@ -31,9 +31,9 @@ import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
@Stable
class BlurHashFetcher(
@@ -43,23 +43,9 @@ class BlurHashFetcher(
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8")
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
val preferredWidth = 100
val bitmap =
BlurHashDecoder.decode(
hash,
preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt(),
)
if (bitmap == null) {
throw Exception("Unable to convert Bluehash $hash")
}
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import android.util.LruCache
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
object CachedRichTextParser {
@@ -39,6 +39,7 @@ object HttpClientManager {
var proxyChangeListeners = ArrayList<() -> Unit>()
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
private var defaultHttpClient: OkHttpClient? = null
private var defaultHttpClientWithoutProxy: OkHttpClient? = null
// fires off every time value of the property changes
private var internalProxy: Proxy? by
@@ -58,6 +59,10 @@ object HttpClientManager {
}
}
fun getDefaultProxy(): Proxy? {
return this.internalProxy
}
fun setDefaultTimeout(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout")
if (this.defaultTimeout.seconds != timeout.seconds) {
@@ -72,7 +77,7 @@ object HttpClientManager {
proxy: Proxy?,
timeout: Duration,
): OkHttpClient {
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
val seconds = if (proxy != null) timeout.seconds * 3 else timeout.seconds
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(proxy)
@@ -98,11 +103,18 @@ object HttpClientManager {
}
}
fun getHttpClient(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
fun getHttpClient(useProxy: Boolean = true): OkHttpClient {
return if (useProxy) {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
}
defaultHttpClient!!
} else {
if (this.defaultHttpClientWithoutProxy == null) {
this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
}
defaultHttpClientWithoutProxy!!
}
return defaultHttpClient!!
}
fun initProxy(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import okhttp3.Call
@@ -42,7 +43,7 @@ object Nip11CachedRetriever {
val retriever = Nip11Retriever()
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
val result = relayInformationDocumentCache.get(retriever.cleanUrl(dirtyUrl)) ?: return null
val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null
if (result is RetrieveResultSuccess) return result.data
return null
}
@@ -52,7 +53,7 @@ object Nip11CachedRetriever {
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
val url = retriever.cleanUrl(dirtyUrl)
val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
if (doc != null) {
@@ -62,35 +63,33 @@ object Nip11CachedRetriever {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
onError(dirtyUrl, doc.error, null)
} else {
Nip11Retriever()
.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
retriever.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
}
}
} else {
Nip11Retriever()
.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
retriever.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
}
}
}
@@ -103,14 +102,6 @@ class Nip11Retriever {
FAIL_WITH_HTTP_STATUS,
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
}
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
@@ -121,8 +112,9 @@ class Nip11Retriever {
try {
val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
val isLocalHost = dirtyUrl.startsWith("ws://127.0.0.1") || dirtyUrl.startsWith("ws://localhost")
HttpClientManager.getHttpClient()
HttpClientManager.getHttpClient(useProxy = !isLocalHost)
.newCall(request)
.enqueue(
object : Callback {
@@ -32,8 +32,7 @@ object Nip96MediaServers {
listOf(
ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("Nostrage", "https://nostrage.com"),
ServerName("Sove", "https://sove.rent"),
ServerName("NostPic", "https://nostpic.com"),
ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat"),
)
@@ -200,8 +200,6 @@ class Nip96Uploader(val account: Account?) {
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
val request =
requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
@@ -35,23 +35,35 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -93,9 +105,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND),
kinds = listOf(StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, PrivateOutboxRelayListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5,
limit = 10,
),
)
}
@@ -111,6 +123,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
@@ -120,24 +136,12 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
fun createAccountSettingsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 10,
),
)
}
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND),
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
),
@@ -149,7 +153,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
@@ -177,8 +181,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
@@ -204,6 +207,36 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createNotificationFilter2(): TypedFilter {
val since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
GitReplyEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarRSVPEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
since = since,
),
)
}
fun createGiftWrapsToMeFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
@@ -211,6 +244,14 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
JsonFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get("&&((GIFTWRAPS_EOSE))&&")
?.relayList
?.mapValues {
EOSETime(it.value.time - TimeUtils.twoDays())
},
),
)
@@ -223,6 +264,13 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
relayUrl,
time,
)
latestEOSEs.addOrUpdate(
account.userProfile(),
"&&((GIFTWRAPS_EOSE))&&",
relayUrl,
time,
)
} else {
hasLoadedTheBasics[account.userProfile()] = true
@@ -237,22 +285,90 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
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)
}
}
}
event.cachedGift(account.signer) { this.consume(it, relay) }
}
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
if (event is SealedGossipEvent) {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
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) {}
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
} else {
LocalCache.justConsume(event, relay)
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.cachedGift(account.signer) {
this.consume(it, relay)
}
}
} else {
// new event
event.cachedGift(account.signer) { this.consume(it, relay) }
LocalCache.justConsume(event, 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
LocalCache.consume(noteEvent, relay)
noteEvent.cachedGossip(account.signer) {
LocalCache.justConsume(it, relay)
}
}
} else {
// new event
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
LocalCache.justConsume(event, 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) {}
}
}
LocalCache.justConsume(event, relay)
}
}
else -> {
LocalCache.justConsume(event, relay)
}
}
}
}
@@ -297,10 +413,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createNotificationFilter2(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountSettingsFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
)
@@ -312,7 +428,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountBookmarkListFilter(),
createAccountSettingsFilter(),
)
.ifEmpty { null }
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
@@ -42,9 +43,9 @@ abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
data class Counter(val subscriptionId: String, val eventKind: Int, var counter: Int)
private var eventCounter = mapOf<String, Counter>()
private var eventCounter = mapOf<Int, Counter>()
var changingFilters = AtomicBoolean()
private var active: Boolean = false
@@ -53,11 +54,18 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter.forEach {
Log.d(
"STATE DUMP ${this.javaClass.simpleName}",
"Received Events ${it.key}: ${it.value.counter}",
"Received Events $debugName ${it.value.subscriptionId} ${it.value.eventKind}: ${it.value.counter}",
)
}
}
fun hashCodeFields(
str1: String,
str2: Int,
): Int {
return 31 * str1.hashCode() + str2.hashCode()
}
private val clientListener =
object : Client.Listener() {
override fun onEvent(
@@ -67,12 +75,12 @@ abstract class NostrDataSource(val debugName: String) {
afterEOSE: Boolean,
) {
if (subscriptions.containsKey(subscriptionId)) {
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
val key = hashCodeFields(subscriptionId, event.kind)
val keyValue = eventCounter[key]
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
eventCounter = eventCounter + Pair(key, Counter(subscriptionId, event.kind, 1))
}
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}")
@@ -221,7 +229,7 @@ abstract class NostrDataSource(val debugName: String) {
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters }
changingFilters.getAndSet(true)
@@ -245,7 +253,7 @@ abstract class NostrDataSource(val debugName: String) {
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) {
Client.close(updatedSubscription.id)
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
@@ -265,7 +273,7 @@ abstract class NostrDataSource(val debugName: String) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) {
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
@@ -293,7 +301,13 @@ abstract class NostrDataSource(val debugName: String) {
eventId: String,
relay: Relay,
) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
val note = LocalCache.getNoteIfExists(eventId)
val noteEvent = note?.event
if (noteEvent is AddressableEvent) {
LocalCache.getAddressableNoteIfExists(noteEvent.address().toTag())?.addRelay(relay)
} else {
note?.addRelay(relay)
}
}
open fun markAsEOSE(
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -131,6 +132,25 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
)
}
fun createNIP89Filter(kTags: List<String>): List<TypedFilter> {
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to kTags),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
)
}
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
@@ -178,9 +198,8 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
filter =
JsonFilter(
authors = follows,
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
since =
latestEOSEs.users[account.userProfile()]
?.followList
@@ -194,7 +213,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
filter =
JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND),
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
since =
latestEOSEs.users[account.userProfile()]
@@ -405,6 +424,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters =
createLiveStreamFilter()
.plus(createNIP89Filter(listOf("5300")))
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
@@ -418,6 +438,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
createPublicChatsGeohashesFilter(),
),
)
.toList()
.ifEmpty { null }
}
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
private var geohashToWatch: String? = null
@@ -61,6 +62,7 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
),
limit = 200,
),
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
@@ -64,6 +65,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
),
limit = 200,
),
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -77,7 +78,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
JsonFilter(
kinds =
@@ -94,6 +95,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
PinListEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = followSet,
limit = 400,
@@ -124,6 +126,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
),
tags =
mapOf(
@@ -160,6 +163,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
),
tags =
mapOf(
@@ -196,6 +200,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
tags =
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlin.coroutines.cancellation.CancellationException
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
@@ -175,6 +176,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
),
search = mySearchString,
limit = 100,
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -63,7 +63,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
// downloads linked events to this event.
return TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
@@ -86,7 +86,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
@@ -23,15 +23,18 @@ 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.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NIP90StatusEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
@@ -57,29 +60,45 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
return groupByEOSEPresence(addressesToWatch).map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
listOf(
DeletionEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}
}.flatten()
}
private fun createAddressFilter(): List<TypedFilter>? {
@@ -93,7 +112,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
it.address()?.let { aTag ->
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
@@ -103,7 +122,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
)
} else {
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
@@ -125,7 +144,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return groupByEOSEPresence(eventsToWatch).map {
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
@@ -147,6 +166,22 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
limit = 1000,
),
),
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
listOf(
DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}.flatten()
}
@@ -159,9 +194,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return groupByEOSEPresence(eventsToWatch).map {
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
@@ -190,7 +226,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
// downloads linked events to this event.
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
ids = interestedEvents.toList(),
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -35,13 +35,13 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
val firstTimers = usersToWatch.filter { it.latestMetadata == null }.map { it.pubkeyHex }
if (firstTimers.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
@@ -54,7 +54,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null }
val secondTimers = usersToWatch.filter { it.latestMetadata != null }
if (secondTimers.isEmpty()) return null
@@ -64,7 +64,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
@@ -73,7 +73,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
@@ -91,7 +91,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
checkNotInMainThread()
usersToWatch.forEach {
if (it.info?.latestMetadata != null) {
if (it.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null
@@ -79,6 +80,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
PinListEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
WikiNoteEvent.KIND,
),
authors = listOf(it.pubkeyHex),
limit = 200,
@@ -40,6 +40,8 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
var job: Job? = null
val SUPPORTED_VIDEO_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
override fun start() {
job?.cancel()
job =
@@ -68,6 +70,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
@@ -93,6 +96,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
),
limit = 100,
since =
@@ -120,6 +124,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
),
limit = 100,
since =
@@ -24,7 +24,11 @@ import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.quartz.crypto.CryptoUtils
import okhttp3.EventListener
import okhttp3.Protocol
import okhttp3.Request
import okio.ByteString.Companion.toByteString
import kotlin.coroutines.cancellation.CancellationException
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
@@ -49,21 +53,44 @@ object OnlineChecker {
return checkOnlineCache.get(url).online
}
Log.d("OnlineChecker", "isOnline $url")
return try {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
val result =
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
if (url.startsWith("wss")) {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.replace("wss+livekit://", "wss://"))
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Key", CryptoUtils.random(16).toByteString().base64())
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Extensions", "permessage-deflate")
.build()
val client =
HttpClientManager.getHttpClient().newBuilder()
.eventListener(EventListener.NONE)
.protocols(listOf(Protocol.HTTP_1_1))
.build()
client.newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
} else {
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
}
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
@@ -35,7 +37,6 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.round
@@ -59,17 +60,35 @@ class ZapPaymentHandler(val account: Account) {
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType,
) = withContext(Dispatchers.IO) {
val zapSplitSetup = note.event?.zapSplitSetup()
val noteEvent = note.event
val zapSplitSetup = noteEvent?.zapSplitSetup()
val zapsToSend =
if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) }
} else if (noteEvent is AppDefinitionEvent) {
val appLud16 = noteEvent.appMetaData()?.lnAddress()
if (appLud16 != null) {
listOf(ZapSplitSetup(appLud16, null, weight = 1.0, true))
} else {
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
onError(
context.getString(R.string.missing_lud16),
context.getString(
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
return@withContext
}
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
} else {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
onError(
@@ -84,101 +103,226 @@ class ZapPaymentHandler(val account: Account) {
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
val totalWeight = zapsToSend.sumOf { it.weight }
val invoicesToPayOnIntent = mutableListOf<Payable>()
zapsToSend.forEachIndexed { index, value ->
val outerProgressMin = index / zapsToSend.size.toFloat()
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
if (value.isLnAddress) {
innerZap(
lud16 = value.lnAddressOrPubKeyHex,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = null,
amountMilliSats = zapValue,
invoice = it,
),
)
},
)
onProgress(0.02f)
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
if (splitZapRequestPairs.isEmpty()) {
onProgress(0.00f)
return@signAllZapRequests
} else {
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
val lud16 = user?.info?.lnAddress()
onProgress(0.05f)
}
if (lud16 != null) {
innerZap(
lud16 = lud16,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
overrideUser = user,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = user,
amountMilliSats = zapValue,
invoice = it,
),
)
},
)
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, onError, onProgress = {
onProgress(it * 0.7f + 0.05f) // keeps within range.
}, context) {
if (it.isEmpty()) {
onProgress(0.00f)
return@assembleAllInvoices
} else {
onError(
context.getString(
R.string.missing_lud16,
),
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex,
),
onProgress(0.75f)
}
if (account.hasWalletConnectSetup()) {
payViaNWC(it.values.map { it.invoice }, note, onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context) {
// onProgress(1f)
}
} else {
onPayViaIntent(
it.map {
Payable(
info = it.key.first,
user = it.key.second.user,
amountMilliSats = it.value.zapValue,
invoice = it.value.invoice,
)
}.toImmutableList(),
)
onProgress(0f)
}
}
}
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
} else {
launch(Dispatchers.IO) {
// Awaits for the event to come back to LocalCache.
var count = 0
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
count++
Thread.sleep(5000)
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
private fun calculateZapValue(
amountMilliSats: Long,
weight: Double,
totalWeight: Double,
): Long {
val shareValue = amountMilliSats * (weight / totalWeight)
val roundedZapValue = round(shareValue / 1000f).toLong() * 1000
return roundedZapValue
}
class SignAllZapRequestsReturn(
val zapRequestJson: String,
val user: User? = null,
)
suspend fun signAllZapRequests(
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
zapsToSend: List<ZapSplitSetup>,
onAllDone: suspend (MutableMap<ZapSplitSetup, SignAllZapRequestsReturn>) -> Unit,
) {
collectSuccessfulSigningOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
operationsInput = zapsToSend,
runRequestFor = { next: ZapSplitSetup, onReady ->
if (next.isLnAddress) {
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson))
}
}
} else {
onProgress(1f)
val user = LocalCache.getUserIfExists(next.lnAddressOrPubKeyHex)
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
}
}
}
},
onReady = onAllDone,
)
}
suspend fun assembleAllInvoices(
invoices: List<Pair<ZapSplitSetup, SignAllZapRequestsReturn>>,
totalAmountMilliSats: Long,
message: String,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (MutableMap<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>) -> Unit,
) {
var progressAllPayments = 0.00f
val totalWeight = invoices.sumOf { it.first.weight }
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
operationsInput = invoices,
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, SignAllZapRequestsReturn>, onReady ->
assembleInvoice(
splitSetup = splitZapRequestPair.first,
nostrZapRequest = splitZapRequestPair.second.zapRequestJson,
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
message = message,
onError = onError,
onProgressStep = { percentStepForThisPayment ->
progressAllPayments += percentStepForThisPayment / invoices.size
onProgress(progressAllPayments)
},
context = context,
onReady = onReady,
)
},
onReady = onAllDone,
)
}
suspend fun payViaNWC(
invoices: List<String>,
note: Note,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (MutableMap<String, Boolean>) -> Unit,
) {
var progressAllPayments = 0.00f
collectSuccessfulSigningOperations<String, Boolean>(
operationsInput = invoices,
runRequestFor = { invoice: String, onReady ->
account.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = note,
onSent = {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
onReady(true)
},
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
onError(
context.getString(R.string.error_dialog_pay_invoice_error),
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
)
} else {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
}
},
)
},
onReady = onAllDone,
)
}
class AssembleInvoiceReturn(
val zapValue: Long,
val invoice: String,
)
private fun assembleInvoice(
splitSetup: ZapSplitSetup,
nostrZapRequest: String,
zapValue: Long,
message: String,
onError: (String, String) -> Unit,
onProgressStep: (percent: Float) -> Unit,
context: Context,
onReady: (AssembleInvoiceReturn) -> Unit,
) {
var progressThisPayment = 0.00f
var user: User? = null
val lud16 =
if (splitSetup.isLnAddress) {
splitSetup.lnAddressOrPubKeyHex
} else {
user = LocalCache.getUserIfExists(splitSetup.lnAddressOrPubKeyHex)
user?.info?.lnAddress()
}
if (lud16 != null) {
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
milliSats = zapValue,
message = message,
nostrRequest = nostrZapRequest,
onError = onError,
onProgress = {
val step = it - progressThisPayment
progressThisPayment = it
onProgressStep(step)
},
context = context,
onSuccess = {
onProgressStep(1 - progressThisPayment)
onReady(AssembleInvoiceReturn(zapValue, it))
},
)
} else {
onError(
context.getString(
R.string.missing_lud16,
),
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: splitSetup.lnAddressOrPubKeyHex,
),
)
}
}
@@ -198,63 +342,4 @@ class ZapPaymentHandler(val account: Account) {
onReady(null)
}
}
private suspend fun innerZap(
lud16: String,
note: Note,
amount: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
) {
onProgress(0.05f)
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
onProgress(0.10f)
LightningAddressResolver()
.lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
context.getString(R.string.error_dialog_pay_invoice_error),
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
)
} else {
onProgress(1f)
}
},
)
onProgress(0.8f)
} else {
onPayInvoiceThroughIntent(it)
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress,
context = context,
)
}
}
}
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl
import okhttp3.Request
import java.math.BigDecimal
import java.math.RoundingMode
@@ -151,20 +150,6 @@ class LightningAddressResolver() {
}
}
fun lnAddressToLnUrl(
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
fetchLightningAddressJson(
lnaddress,
onSuccess = { onSuccess(it.toByteArray().toLnUrl()) },
onError = onError,
context = context,
)
}
fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
@@ -190,7 +175,8 @@ class LightningAddressResolver() {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup,
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
lnaddress,
),
)
null
@@ -202,7 +188,8 @@ class LightningAddressResolver() {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
lnaddress,
),
)
}
@@ -227,7 +214,8 @@ class LightningAddressResolver() {
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup,
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
lnaddress,
),
)
null
@@ -268,7 +256,8 @@ class LightningAddressResolver() {
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
lnaddress,
reason,
),
)
@@ -279,7 +268,8 @@ class LightningAddressResolver() {
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
lnaddress,
),
)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -45,6 +46,7 @@ import java.math.BigDecimal
class EventNotificationConsumer(private val applicationContext: Context) {
suspend fun consume(event: GiftWrapEvent) {
Log.d("EventNotificationConsumer", "New Notification Arrived")
if (!LocalCache.justVerify(event)) return
if (!notificationManager().areNotificationsEnabled()) return
@@ -64,15 +66,26 @@ class EventNotificationConsumer(private val applicationContext: Context) {
account: Account,
) {
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
LocalCache.justConsume(notificationEvent, null)
val consumed = LocalCache.hasConsumed(notificationEvent)
val verified = LocalCache.justVerify(notificationEvent)
Log.d("EventNotificationConsumer", "New Notification Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified")
if (!consumed && verified) {
Log.d("EventNotificationConsumer", "New Notification was verified")
unwrapAndConsume(notificationEvent, account) { innerEvent ->
unwrapAndConsume(notificationEvent, account) { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
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)
} else if (innerEvent is LnZapEvent) {
Log.d("EventNotificationConsumer", "New Zap to Notify")
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
notify(innerEvent, account)
}
}
}
}
}
@@ -84,6 +97,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
if (LocalCache.hasConsumed(event)) return
when (event) {
is GiftWrapEvent -> {
@@ -91,9 +105,11 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
is SealedGossipEvent -> {
event.cachedGossip(account.signer) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
if (!LocalCache.hasConsumed(it)) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
}
}
else -> {
@@ -108,7 +124,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
acc: Account,
) {
if (
event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
event.createdAt > TimeUtils.fifteenMinutesAgo() && // old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
@@ -148,7 +164,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
@@ -187,7 +203,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped =
@@ -195,7 +211,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
if (event.isTaggedUser(acc.userProfile().pubkeyHex)) {
val amount = showAmount(event.amount)
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
acc.decryptZapContentAuthor(noteZapRequest) {
@@ -27,6 +27,7 @@ import android.util.LruCache
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Player.PositionInfo
import androidx.media3.common.Player.STATE_IDLE
import androidx.media3.common.Player.STATE_READY
import androidx.media3.exoplayer.ExoPlayer
@@ -143,6 +144,14 @@ class MultiPlayerPlaybackManager(
}
}
}
override fun onPositionDiscontinuity(
oldPosition: PositionInfo,
newPosition: PositionInfo,
reason: Int,
) {
cachedPositions.add(uri, newPosition.positionMs)
}
},
)
@@ -27,13 +27,13 @@ import android.util.Log
import android.util.LruCache
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.CancellationException
import java.util.concurrent.Executors
object PlaybackClientController {
var executorService = Executors.newCachedThreadPool()
val cache = LruCache<Int, SessionToken>(1)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun prepareController(
controllerID: String,
videoUri: String,
@@ -67,7 +67,7 @@ object PlaybackClientController {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
MoreExecutors.directExecutor(),
executorService,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -23,62 +23,74 @@ package com.vitorpamplona.amethyst.service.playback
import android.content.Intent
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HttpClientManager
import okhttp3.OkHttpClient
class WssOrHttpFactory(httpClient: OkHttpClient) : MediaSource.Factory {
@UnstableApi
val http = DefaultMediaSourceFactory(OkHttpDataSource.Factory(httpClient))
@UnstableApi
val wss = DefaultMediaSourceFactory(WssStreamDataSource.Factory(httpClient))
@OptIn(UnstableApi::class)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
http.setDrmSessionManagerProvider(drmSessionManagerProvider)
wss.setDrmSessionManagerProvider(drmSessionManagerProvider)
return this
}
@OptIn(UnstableApi::class)
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
http.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
wss.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
return this
}
@OptIn(UnstableApi::class)
override fun getSupportedTypes(): IntArray {
return http.supportedTypes
}
@OptIn(UnstableApi::class)
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
return if (mediaItem.mediaId.startsWith("wss")) {
wss.createMediaSource(mediaItem)
} else {
http.createMediaSource(mediaItem)
}
}
}
@UnstableApi // Extend MediaSessionService
class PlaybackService : MediaSessionService() {
private var videoViewedPositionCache = VideoViewedPositionCache()
private var managerHls: MultiPlayerPlaybackManager? = null
private var managerProgressive: MultiPlayerPlaybackManager? = null
private var managerLocal: MultiPlayerPlaybackManager? = null
private var managerAllInOne: MultiPlayerPlaybackManager? = null
fun newHslDataSource(): MediaSource.Factory {
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
@OptIn(UnstableApi::class)
fun newAllInOneDataSource(): MediaSource.Factory {
// This might be needed for live kit.
// return WssOrHttpFactory(HttpClientManager.getHttpClient())
return DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
}
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(
(applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()),
)
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.let {
fun lazyDS(): MultiPlayerPlaybackManager {
managerAllInOne?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerHls = newInstance
return newInstance
}
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
managerProgressive?.let {
return it
}
val newInstance =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerProgressive = newInstance
return newInstance
}
fun lazyLocalDS(): MultiPlayerPlaybackManager {
managerLocal?.let {
return it
}
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
managerLocal = newInstance
val newInstance = MultiPlayerPlaybackManager(newAllInOneDataSource(), videoViewedPositionCache)
managerAllInOne = newInstance
return newInstance
}
@@ -94,15 +106,11 @@ class PlaybackService : MediaSessionService() {
}
private fun onProxyUpdated() {
val toDestroyHls = managerHls
val toDestroyProgressive = managerProgressive
val toDestroyAllInOne = managerAllInOne
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive =
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerAllInOne = MultiPlayerPlaybackManager(newAllInOneDataSource(), videoViewedPositionCache)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
toDestroyAllInOne?.releaseAppPlayers()
}
override fun onTaskRemoved(rootIntent: Intent?) {
@@ -116,23 +124,11 @@ class PlaybackService : MediaSessionService() {
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers()
managerProgressive?.releaseAppPlayers()
managerAllInOne?.releaseAppPlayers()
super.onDestroy()
}
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
return if (fileName.startsWith("file")) {
lazyLocalDS()
} else if (fileName.endsWith("m3u8")) {
lazyHlsDS()
} else {
lazyProgressiveDS()
}
}
override fun onUpdateNotification(
session: MediaSession,
startInForegroundRequired: Boolean,
@@ -141,38 +137,18 @@ class PlaybackService : MediaSessionService() {
super.onUpdateNotification(session, startInForegroundRequired)
// Overrides the notification with any player actually playing
managerHls?.playingContent()?.forEach {
managerAllInOne?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
managerLocal?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
managerProgressive?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
// Overrides again with playing with audio
managerHls?.playingContent()?.forEach {
managerAllInOne?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
managerLocal?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
managerProgressive?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
}
// Return a MediaSession to link with the MediaController that is making
@@ -182,9 +158,9 @@ class PlaybackService : MediaSessionService() {
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
val manager = getAppropriateMediaSessionManager(uri)
val manager = lazyDS()
return manager?.getMediaSession(
return manager.getMediaSession(
id,
uri,
callbackUri,
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.util.concurrent.ConcurrentSkipListSet
class WssDataStreamCollector : WebSocketListener() {
private val wssData = ConcurrentSkipListSet<ByteString>()
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) {
wssData.add(bytes)
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
super.onClosing(webSocket, code, reason)
wssData.removeAll(wssData)
}
fun canStream(): Boolean {
return wssData.size > 0
}
fun getNextStream(): ByteString {
return wssData.pollFirst()
}
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.BaseDataSource
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import kotlin.math.min
@OptIn(UnstableApi::class)
class WssStreamDataSource(val httpClient: OkHttpClient) : BaseDataSource(true) {
val dataStreamCollector: WssDataStreamCollector = WssDataStreamCollector()
var webSocketClient: WebSocket? = null
private var currentByteStream: ByteArray? = null
private var currentPosition = 0
private var remainingBytes = 0
override fun open(dataSpec: DataSpec): Long {
// Form the request and open the socket.
// Provide the listener
// which collects the data for us (Previous class).
webSocketClient =
httpClient.newWebSocket(
Request.Builder().apply {
dataSpec.httpRequestHeaders.forEach { entry ->
addHeader(entry.key, entry.value)
}
}.url(dataSpec.uri.toString()).build(),
dataStreamCollector,
)
return -1 // Return -1 as the size is unknown (streaming)
}
override fun getUri(): Uri? {
webSocketClient?.request()?.url?.let {
return Uri.parse(it.toString())
}
return null
}
override fun read(
target: ByteArray,
offset: Int,
length: Int,
): Int {
// return 0 (nothing read) when no data present...
if (currentByteStream == null && !dataStreamCollector.canStream()) {
return 0
}
// parse one (data) ByteString at a time.
// reset the current position and remaining bytes
// for every new data
if (currentByteStream == null) {
currentByteStream = dataStreamCollector.getNextStream().toByteArray()
currentPosition = 0
remainingBytes = currentByteStream?.size ?: 0
}
val readSize = min(length, remainingBytes)
currentByteStream?.copyInto(target, offset, currentPosition, currentPosition + readSize)
currentPosition += readSize
remainingBytes -= readSize
// once the data is read set currentByteStream to null
// so the next data would be collected to process in next
// iteration.
if (remainingBytes == 0) {
currentByteStream = null
}
return readSize
}
override fun close() {
// close the socket and relase the resources
webSocketClient?.cancel()
}
// Factory class for DataSource
class Factory(val okHttpClient: OkHttpClient) : DataSource.Factory {
override fun createDataSource(): DataSource = WssStreamDataSource(okHttpClient)
}
}
@@ -21,18 +21,14 @@
package com.vitorpamplona.amethyst.service.previews
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
suspend fun fetchUrlPreview(timeOut: Int = 30000) =
withContext(Dispatchers.IO) {
try {
fetch(timeOut)
} catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
try {
fetch(timeOut)
} catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
private suspend fun fetch(timeOut: Int = 30000) {
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.commons.preview.MetaTag
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.Dispatchers
@@ -27,60 +29,39 @@ import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import okio.BufferedSource
import okio.ByteString.Companion.decodeHex
import okio.Options
import java.nio.charset.Charset
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
private const val ATTRIBUTE_VALUE_CHARSET = "charset"
private const val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
// for <meta itemprop=... to get title
private val META_X_TITLE =
arrayOf(
"og:title",
"\"og:title\"",
"'og:title'",
"name",
"\"name\"",
"'name'",
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'",
)
// for <meta itemprop=... to get description
private val META_X_DESCRIPTION =
arrayOf(
"og:description",
"\"og:description\"",
"'og:description'",
"description",
"\"description\"",
"'description'",
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'",
)
// for <meta itemprop=... to get image
private val META_X_IMAGE =
arrayOf(
"og:image",
"\"og:image\"",
"'og:image'",
"image",
"\"image\"",
"'image'",
"twitter:image",
"\"twitter:image\"",
"'twitter:image'",
"image",
)
private const val CONTENT = "content"
@@ -95,14 +76,12 @@ suspend fun getDocument(
checkNotInMainThread()
if (it.isSuccessful) {
val mimeType =
it.headers.get("Content-Type")?.toMediaType()
it.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException(
"Website returned unknown mimetype: ${it.headers.get("Content-Type")}",
"Website returned unknown mimetype: ${it.headers["Content-Type"]}",
)
if (mimeType.type == "text" && mimeType.subtype == "html") {
val document = Jsoup.parse(it.body.string())
parseHtml(url, document, mimeType)
parseHtml(url, it.body.source(), mimeType)
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else if (mimeType.type == "video") {
@@ -120,65 +99,141 @@ suspend fun getDocument(
suspend fun parseHtml(
url: String,
document: Document,
source: BufferedSource,
type: MediaType,
): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
// sniff charset from Content-Type header or BOM
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
if (sniffedCharset != null) {
val metaTags = MetaTagsParser.parse(source.readByteArray().toString(sniffedCharset))
return@withContext extractUrlInfo(url, metaTags, type)
}
var title: String = ""
var description: String = ""
var image: String = ""
// if sniffing was failed, detect charset from content
val bodyBytes = source.readByteArray()
val charset = detectCharset(bodyBytes)
val metaTags = MetaTagsParser.parse(bodyBytes.toString(charset))
return@withContext extractUrlInfo(url, metaTags, type)
}
metaTags.forEach {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
// taken from okhttp
private val UNICODE_BOMS =
Options.of(
// UTF-8
"efbbbf".decodeHex(),
// UTF-16BE
"feff".decodeHex(),
// UTF-16LE
"fffe".decodeHex(),
// UTF-32BE
"0000ffff".decodeHex(),
// UTF-32LE
"ffff0000".decodeHex(),
)
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
private fun BufferedSource.readBomAsCharset(): Charset? {
return when (select(UNICODE_BOMS)) {
0 -> Charsets.UTF_8
1 -> Charsets.UTF_16BE
2 -> Charsets.UTF_16LE
3 -> Charsets.UTF_32BE
4 -> Charsets.UTF_32LE
-1 -> null
else -> throw AssertionError()
}
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return@withContext UrlInfoItem(url, title, description, image, type)
private fun detectCharset(bodyBytes: ByteArray): Charset {
// try to detect charset from meta tags parsed from first 1024 bytes of body
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
val metaTags = MetaTagsParser.parse(firstPart)
metaTags.forEach { meta ->
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
if (charsetAttr.isNotEmpty()) {
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
return it
}
}
return@withContext UrlInfoItem(url, title, description, image, type)
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
RE_CONTENT_TYPE_CHARSET.find(meta.attr(CONTENT))
?.let {
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
}?.let {
return it
}
}
}
// defaults to UTF-8
return Charset.forName("utf-8")
}
private fun extractUrlInfo(
url: String,
metaTags: Sequence<MetaTag>,
type: MediaType,
): UrlInfoItem {
var title: String = ""
var description: String = ""
var image: String = ""
metaTags.forEach {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return UrlInfoItem(url, title, description, image, type)
}
}
return UrlInfoItem(url, title, description, image, type)
}
@@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
@@ -45,7 +44,7 @@ object Client : RelayPool.Listener {
relays: Array<Relay>?,
onlyIfChanged: Boolean = false,
) {
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.read + " " + it.write + " " + it.activeTypes.joinToString(",") { it.name } }}")
checkNotInMainThread()
if (onlyIfChanged) {
@@ -98,7 +97,35 @@ object Client : RelayPool.Listener {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
RelayPool.sendFilter(subscriptionId, filters)
}
fun sendFilterAndStopOnFirstResponse(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf(),
onResponse: (Event) -> Unit,
) {
checkNotInMainThread()
subscribe(
object : Listener() {
override fun onEvent(
event: Event,
subId: String,
relay: Relay,
afterEOSE: Boolean,
) {
if (subId == subscriptionId) {
onResponse(event)
unsubscribe(this)
close(subscriptionId)
}
}
},
)
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId, filters)
}
fun sendFilterOnlyIfDisconnected(
@@ -125,45 +152,21 @@ object Client : RelayPool.Listener {
} else if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach { it.send(signedEvent) }
} else {
/** temporary connection */
newSporadicRelay(
relay,
feedTypes,
onConnected = { relay -> relay.send(signedEvent) },
onDone = onDone,
)
RelayPool.getOrCreateRelay(relay, feedTypes, onDone) {
it.send(signedEvent)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun newSporadicRelay(
url: String,
feedTypes: Set<FeedType>?,
onConnected: (Relay) -> Unit,
onDone: (() -> Unit)?,
fun sendPrivately(
signedEvent: EventInterface,
relayList: List<String>,
) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
RelayPool.addRelay(relay)
checkNotInMainThread()
relay.connectAndRun {
allSubscriptions().forEach { relay.sendFilter(requestId = it) }
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(60000) // waits for a reply
relay.disconnect()
RelayPool.removeRelay(relay)
if (onDone != null) {
onDone()
}
relayList.forEach { relayUrl ->
RelayPool.getOrCreateRelay(relayUrl, setOf(FeedType.PRIVATE_DMS), { }) {
it.sendOverride(signedEvent)
}
}
}
@@ -264,8 +267,8 @@ object Client : RelayPool.Listener {
listeners = listeners.minus(listener)
}
fun allSubscriptions(): Set<String> {
return subscriptions.keys
fun allSubscriptions(): Map<String, List<TypedFilter>> {
return subscriptions
}
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
@@ -35,144 +35,22 @@ object Constants {
val defaultRelays =
arrayOf(
// Free relays for only DMs and Follows due to the amount of spam
// Free relays for only DMs, Chats and Follows due to the amount of spam
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.fmt.wiz.biz", read = true, write = false, feedTypes = activeTypesChats),
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
// Chats
RelaySetupInfo(
"wss://nostr.bitcoiner.social",
read = true,
write = true,
feedTypes = activeTypesChats,
),
RelaySetupInfo(
"wss://relay.nostr.bg",
read = true,
write = true,
feedTypes = activeTypesChats,
),
RelaySetupInfo(
"wss://nostr.oxtr.dev",
read = true,
write = true,
feedTypes = activeTypesChats,
),
RelaySetupInfo(
"wss://nostr-pub.wellorder.net",
read = true,
write = true,
feedTypes = activeTypesChats,
),
// Global
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
// Less Reliable
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true,
// feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes
// = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true,
// feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes =
// activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true,
// feedTypes = activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes =
// activeTypes),
// NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes
// = activeTypes),
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes =
// activeTypes),
// Paid relays
RelaySetupInfo(
"wss://relay.snort.social",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://relay.nostr.com.au",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://eden.nostr.land",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://nostr.milou.lol",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://puravida.nostr.land",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://nostr.wine",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://nostr.inosta.cc",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://atlas.nostr.land",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://relay.orangepill.dev",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo(
"wss://relay.nostrati.com",
read = true,
write = false,
feedTypes = activeTypesGlobalChats,
),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
// Supporting NIP-50
RelaySetupInfo(
"wss://relay.nostr.band",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(
"wss://relay.noswhere.com",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
)
val forcedRelayForSearch =
arrayOf(
RelaySetupInfo(
"wss://relay.nostr.band",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(
"wss://relay.noswhere.com",
read = true,
write = false,
feedTypes = activeTypesSearch,
),
)
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
val defaultSearchRelaySet = setOf("wss://relay.nostr.band", "wss://nostr.wine", "wss://relay.noswhere.com")
}
@@ -50,6 +50,9 @@ enum class FeedType {
val COMMON_FEED_TYPES =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
val EVENT_FINDER_TYPES =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL)
class Relay(
val url: String,
val read: Boolean = true,
@@ -63,7 +66,12 @@ class Relay(
const val RECONNECTING_IN_SECONDS = 60 * 3
}
private val httpClient = HttpClientManager.getHttpClient()
private val httpClient =
if (url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")) {
HttpClientManager.getHttpClient(false)
} else {
HttpClientManager.getHttpClient()
}
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
@@ -82,6 +90,7 @@ class Relay(
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
val authResponse = mutableMapOf<HexKey, Boolean>()
val sendWhenReady = mutableListOf<EventInterface>()
fun register(listener: Listener) {
listeners = listeners.plus(listener)
@@ -107,7 +116,7 @@ class Relay(
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Relay", "Relay.connect $url hasProxy: ${this.httpClient.proxy != null}")
Log.d("Relay", "Relay.connect $url connecting: ${connectingBlock.get()} hasProxy: ${this.httpClient.proxy != null}")
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
@@ -116,13 +125,16 @@ class Relay(
return
}
checkNotInMainThread()
if (socket != null) return
lastConnectTentative = TimeUtils.now()
try {
checkNotInMainThread()
if (socket != null) {
connectingBlock.set(false)
return
}
lastConnectTentative = TimeUtils.now()
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
@@ -159,6 +171,13 @@ class Relay(
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
onConnected(this@Relay)
synchronized(sendWhenReady) {
sendWhenReady.forEach {
send(it)
}
sendWhenReady.clear()
}
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) }
}
@@ -264,6 +283,7 @@ class Relay(
val event = Event.fromJson(msgArray.get(2))
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
listeners.forEach {
it.onEvent(
this@Relay,
@@ -344,19 +364,23 @@ class Relay(
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
}
fun sendFilter(requestId: String) {
fun sendFilter(
requestId: String,
filters: List<TypedFilter>,
) {
checkNotInMainThread()
if (read) {
if (isConnected()) {
if (isReady) {
val filters =
Client.getSubscriptionFilters(requestId).filter { filter ->
val relayFilters =
filters.filter { filter ->
activeTypes.any { it in filter.types }
}
if (filters.isNotEmpty()) {
if (relayFilters.isNotEmpty()) {
val request =
filters.joinToStringLimited(
relayFilters.joinToStringLimited(
separator = ",",
limit = 20,
prefix = """["REQ","$requestId",""",
@@ -423,7 +447,41 @@ class Relay(
fun renewFilters() {
// Force update all filters after AUTH.
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
Client.allSubscriptions().forEach {
sendFilter(requestId = it.key, it.value)
}
}
// This function sends the event regardless of the relay being write or not.
fun sendOverride(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
} else {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (isConnected()) {
if (isReady) {
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
renewFilters()
}
}
}
}
fun send(signedEvent: EventInterface) {
@@ -442,6 +500,10 @@ class Relay(
if (isReady) {
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
} else {
synchronized(sendWhenReady) {
sendWhenReady.add(signedEvent)
}
}
} else {
// sends all filters after connection is successful.
@@ -452,7 +514,7 @@ class Relay(
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
renewFilters()
}
}
}
@@ -24,10 +24,15 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
@@ -58,6 +63,57 @@ object RelayPool : Relay.Listener {
return relays.filter { it.url == url }
}
fun getOrCreateRelay(
url: String,
feedTypes: Set<FeedType>? = null,
onDone: (() -> Unit)? = null,
whenConnected: (Relay) -> Unit,
) {
synchronized(this) {
val matching = getRelays(url)
if (matching.isNotEmpty()) {
matching.forEach { whenConnected(it) }
} else {
/** temporary connection */
newSporadicRelay(
url,
feedTypes,
onConnected = whenConnected,
onDone = onDone,
)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(
url: String,
feedTypes: Set<FeedType>?,
onConnected: (Relay) -> Unit,
onDone: (() -> Unit)?,
) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
addRelay(relay)
relay.connectAndRun {
Client.allSubscriptions().forEach {
relay.sendFilter(it.key, it.value)
}
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(60000) // waits for a reply
relay.disconnect()
removeRelay(relay)
if (onDone != null) {
onDone()
}
}
}
}
fun loadRelays(relayList: List<Relay>) {
if (!relayList.isNullOrEmpty()) {
relayList.forEach { addRelay(it) }
@@ -77,8 +133,13 @@ object RelayPool : Relay.Listener {
relays.forEach { it.connect() }
}
fun sendFilter(subscriptionId: String) {
relays.forEach { it.sendFilter(subscriptionId) }
fun sendFilter(
subscriptionId: String,
filters: List<TypedFilter>,
) {
relays.forEach { relay ->
relay.sendFilter(subscriptionId, filters)
}
}
fun connectAndSendFiltersIfDisconnected() {
@@ -89,13 +150,17 @@ object RelayPool : Relay.Listener {
list: List<Relay>,
signedEvent: EventInterface,
) {
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } }
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.sendOverride(signedEvent) } }
}
fun send(signedEvent: EventInterface) {
relays.forEach { it.send(signedEvent) }
}
fun sendOverride(signedEvent: EventInterface) {
relays.forEach { it.sendOverride(signedEvent) }
}
fun close(subscriptionId: String) {
relays.forEach { it.close(subscriptionId) }
}
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.relays
import com.fasterxml.jackson.databind.JsonNode
import com.vitorpamplona.quartz.events.Event
import java.util.UUID
data class Subscription(
@@ -37,23 +35,36 @@ data class Subscription(
onEOSE?.let { it(time, relay) }
}
fun toJson(): String {
return Event.mapper.writeValueAsString(toJsonObject())
}
fun hasChangedFiltersFrom(otherFilters: List<TypedFilter>?): Boolean {
if (typedFilters == null && otherFilters == null) return false
if (typedFilters?.size != otherFilters?.size) return true
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
typedFilters?.forEachIndexed { index, typedFilter ->
val otherFilter = otherFilters?.getOrNull(index) ?: return true
return factory.objectNode().apply {
put("id", id)
typedFilters?.also { filters ->
replace(
"typedFilters",
factory.arrayNode(filters.size).apply {
filters.forEach { filter -> add(filter.toJsonObject()) }
},
)
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
// fast check
if (typedFilter.filter.authors?.size != otherFilter.filter.authors?.size ||
typedFilter.filter.ids?.size != otherFilter.filter.ids?.size ||
typedFilter.filter.tags?.size != otherFilter.filter.tags?.size ||
typedFilter.filter.kinds?.size != otherFilter.filter.kinds?.size ||
typedFilter.filter.limit != otherFilter.filter.limit ||
typedFilter.filter.search?.length != otherFilter.filter.search?.length ||
typedFilter.filter.until != otherFilter.filter.until
) {
return true
}
// deep check
if (typedFilter.filter.ids != otherFilter.filter.ids ||
typedFilter.filter.authors != otherFilter.filter.authors ||
typedFilter.filter.tags != otherFilter.filter.tags ||
typedFilter.filter.kinds != otherFilter.filter.kinds ||
typedFilter.filter.search != otherFilter.filter.search
) {
return true
}
}
return false
}
}
@@ -20,73 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relays
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ArrayNode
import com.vitorpamplona.quartz.events.Event
class TypedFilter(
val types: Set<FeedType>,
val filter: JsonFilter,
) {
fun toJson(): String {
return Event.mapper.writeValueAsString(toJsonObject())
}
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
replace("types", typesToJson(types))
replace("filter", filterToJson(filter))
}
}
fun typesToJson(types: Set<FeedType>): ArrayNode {
val factory = Event.mapper.nodeFactory
return factory.arrayNode(types.size).apply { types.forEach { add(it.name.lowercase()) } }
}
fun filterToJson(filter: JsonFilter): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
filter.ids?.run {
replace(
"ids",
factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } },
)
}
filter.authors?.run {
replace(
"authors",
factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } },
)
}
filter.kinds?.run {
replace(
"kinds",
factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } },
)
}
filter.tags?.run {
entries.forEach { kv ->
replace(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)
}
}
/*
Does not include since in the json comparison
filter.since?.run {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
}
jsonObject.add("since", jsonObjectSince)
}*/
filter.until?.run { put("until", filter.until) }
filter.limit?.run { put("limit", filter.limit) }
filter.search?.run { put("search", filter.search) }
}
}
}
)
@@ -0,0 +1,70 @@
/**
* 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
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
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
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
val displayFeatures = calculateDisplayFeatures(act)
val windowSizeClass = calculateWindowSizeClass(act)
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(act.isOnMobileDataState) {
sharedPreferencesViewModel.updateConnectivityStatusState(act.isOnMobileDataState)
}
return sharedPreferencesViewModel
}
@Composable
fun AppScreen(
sharedPreferencesViewModel: SharedPreferencesViewModel,
serviceManager: ServiceManager,
) {
AmethystTheme(sharedPreferencesViewModel) {
val accountStateViewModel: AccountStateViewModel = viewModel()
accountStateViewModel.serviceManager = serviceManager
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
}
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
}
@@ -33,16 +33,7 @@ import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.LocalCache
@@ -53,10 +44,6 @@ import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.events.ChannelCreateEvent
@@ -76,14 +63,13 @@ import java.util.Timer
import kotlin.concurrent.schedule
class MainActivity : AppCompatActivity() {
private val isOnMobileDataState = mutableStateOf(false)
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
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -91,36 +77,8 @@ class MainActivity : AppCompatActivity() {
Log.d("Lifetime Event", "MainActivity.onCreate")
setContent {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
val displayFeatures = calculateDisplayFeatures(this)
val windowSizeClass = calculateWindowSizeClass(this)
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(isOnMobileDataState) {
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
}
AmethystTheme(sharedPreferencesViewModel) {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
val accountStateViewModel: AccountStateViewModel = viewModel()
accountStateViewModel.serviceManager = serviceManager
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
}
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
}
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel, serviceManager = serviceManager)
}
}
@@ -46,6 +46,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -87,7 +88,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.BechLink
@@ -98,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@@ -268,6 +270,7 @@ fun EditPostView(
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
quotesLeft = 1,
modifier = MaterialTheme.colorScheme.replyModifier,
accountViewModel = accountViewModel,
nav = nav,
@@ -312,11 +315,12 @@ fun EditPostView(
val backgroundColor = remember { mutableStateOf(bgColor) }
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav,
word = myUrlPreview,
canPreview = true,
quotesLeft = 1,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
@@ -385,7 +389,7 @@ fun EditPostView(
fontWeight = FontWeight.W500,
)
Divider()
HorizontalDivider(thickness = DividerThickness)
MyTextField(
value = postViewModel.subject,
@@ -446,6 +450,9 @@ fun ShowUserSuggestionListForEdit(
key = { _, item -> item.pubkeyHex },
) { _, item ->
UserLine(item, accountViewModel) { editPostViewModel.autocompleteWithUser(item) }
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@@ -31,8 +31,8 @@ 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.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -257,7 +257,7 @@ open class EditPostViewModel() : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
} else {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
@@ -36,7 +37,12 @@ class ImageDownloader {
try {
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc = url.openConnection() as HttpURLConnection
var huc =
if (HttpClientManager.getDefaultProxy() != null) {
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
@@ -45,7 +51,12 @@ class ImageDownloader {
// open the new connnection again
url = URL(newUrl)
huc = url.openConnection() as HttpURLConnection
huc =
if (HttpClientManager.getDefaultProxy() != null) {
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
responseCode = huc.responseCode
}
@@ -37,7 +37,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -142,7 +142,10 @@ fun JoinUserOrChannelView(
) {
Surface {
Column(
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp),
modifier =
Modifier
.padding(10.dp)
.heightIn(min = 500.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -267,7 +270,10 @@ private fun SearchEditTextForJoin(
}
Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
modifier =
Modifier
.padding(horizontal = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -280,7 +286,8 @@ private fun SearchEditTextForJoin(
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) },
modifier =
Modifier.weight(1f, true)
Modifier
.weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester)
.onFocusChanged {
@@ -330,7 +337,11 @@ private fun RenderSearchResults(
}
Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(vertical = 10.dp),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
@@ -346,6 +357,10 @@ private fun RenderSearchResults(
searchBarViewModel.clear()
}
HorizontalDivider(
thickness = DividerThickness,
)
}
itemsIndexed(
@@ -356,6 +371,10 @@ private fun RenderSearchResults(
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@@ -393,36 +412,30 @@ fun UserComposeForChat(
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
Column(
Row(
modifier =
Modifier.clickable(
onClick = onClick,
).padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Column(
modifier =
Modifier.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) }
Column(
modifier = Modifier.padding(start = 10.dp).weight(1f),
) {
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) }
DisplayUserAboutInfo(baseUser)
}
DisplayUserAboutInfo(baseUser)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = DividerThickness,
)
}
}
@@ -146,9 +146,9 @@ class NewMessageTagger(
fun getNostrAddress(
bechAddress: String,
restOfTheWord: String,
restOfTheWord: String?,
): String {
return if (restOfTheWord.isEmpty()) {
return if (restOfTheWord.isNullOrEmpty()) {
"nostr:$bechAddress"
} else {
if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) {
@@ -159,7 +159,7 @@ class NewMessageTagger(
}
}
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String)
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String?)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey
@@ -181,7 +181,7 @@ class NewMessageTagger(
val pubkey =
Nip19Bech32.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
} else if (key.startsWith("npub1", true)) {
if (key.length < 63) {
return null
@@ -192,7 +192,7 @@ class NewMessageTagger(
val pubkey = Nip19Bech32.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
} else if (key.startsWith("note1", true)) {
if (key.length < 63) {
return null
@@ -203,7 +203,7 @@ class NewMessageTagger(
val noteId = Nip19Bech32.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(noteId, restOfWord)
return DirtyKeyInfo(noteId, restOfWord.ifEmpty { null })
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19Bech32.uriToRoute(key) ?: return null
@@ -45,7 +45,9 @@ fun NewPollOption(
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = { pollViewModel.pollOptions.remove(optionIndex) },
onClick = {
pollViewModel.removePollOption(optionIndex)
},
) {
Icon(
imageVector = Icons.Default.Delete,
@@ -57,7 +59,9 @@ fun NewPollOption(
OutlinedTextField(
modifier = Modifier.weight(1F),
value = pollViewModel.pollOptions[optionIndex] ?: "",
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
onValueChange = {
pollViewModel.updatePollOption(optionIndex, it)
},
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
@@ -56,8 +56,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.LocationOff
@@ -66,12 +64,11 @@ import androidx.compose.material.icons.filled.Sell
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -128,7 +125,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip96MediaServers
@@ -147,6 +144,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.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
@@ -154,6 +152,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
@@ -168,18 +167,21 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
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)
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@Composable
fun NewPostView(
onClose: () -> Unit,
@@ -187,6 +189,7 @@ fun NewPostView(
quote: Note? = null,
fork: Note? = null,
version: Note? = null,
draft: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -201,10 +204,21 @@ fun NewPostView(
var showRelaysDialog by remember { mutableStateOf(false) }
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
LaunchedEffect(Unit) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
LaunchedEffect(key1 = postViewModel.draftTag) {
launch(Dispatchers.IO) {
postViewModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
postViewModel.sendDraft(relayList = relayList)
}
}
}
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() }
}
@@ -221,7 +235,12 @@ fun NewPostView(
}
Dialog(
onDismissRequest = { onClose() },
onDismissRequest = {
scope.launch {
postViewModel.sendDraftSync(relayList = relayList)
onClose()
}
},
properties =
DialogProperties(
usePlatformDefaultWidth = false,
@@ -280,8 +299,9 @@ fun NewPostView(
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.cancel()
scope.launch {
postViewModel.sendDraftSync(relayList = relayList)
postViewModel.cancel()
delay(100)
onClose()
}
@@ -338,6 +358,7 @@ fun NewPostView(
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
quotesLeft = 1,
modifier = MaterialTheme.colorScheme.replyModifier,
accountViewModel = accountViewModel,
nav = nav,
@@ -352,7 +373,7 @@ fun NewPostView(
}
}
if (enableMessageInterface) {
if (postViewModel.wantsDirectMessage) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
@@ -415,11 +436,12 @@ fun NewPostView(
val backgroundColor = remember { mutableStateOf(bgColor) }
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav,
word = myUrlPreview,
canPreview = true,
quotesLeft = 1,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
@@ -581,7 +603,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
postViewModel.toggleMarkAsSensitive()
}
AddGeoHash(postViewModel) {
@@ -725,7 +747,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.add_sensitive_content_explainer),
@@ -772,7 +794,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -806,7 +828,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -827,7 +849,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.title,
onValueChange = { postViewModel.title = it },
onValueChange = {
postViewModel.updateTitle(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -847,7 +871,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -863,13 +887,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
value = postViewModel.price,
onValueChange = {
runCatching {
if (it.text.isEmpty()) {
postViewModel.price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
postViewModel.price = it
}
}
postViewModel.updatePrice(it)
},
placeholder = {
Text(
@@ -890,7 +908,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -934,7 +952,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
TextSpinner(
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
options = conditionOptions,
onSelect = { postViewModel.condition = conditionTypes[it].first },
onSelect = {
postViewModel.updateCondition(conditionTypes[it].first)
},
modifier =
Modifier
.weight(1f)
@@ -955,7 +975,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
}
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -998,7 +1018,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second
?: "",
options = categoryOptions,
onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) },
onSelect = {
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
},
modifier =
Modifier
.weight(1f)
@@ -1019,7 +1041,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
}
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1033,7 +1055,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.locationText,
onValueChange = { postViewModel.locationText = it },
onValueChange = {
postViewModel.updateLocation(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -1053,7 +1077,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -1072,40 +1096,21 @@ fun FowardZapTo(
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Box(
Modifier
.height(20.dp)
.width(25.dp),
) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier =
Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange,
)
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange,
)
}
ZapSplitIcon()
Text(
text = stringResource(R.string.zap_split_title),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
modifier = Modifier.padding(horizontal = 10.dp).weight(1f),
)
OutlinedButton(onClick = { postViewModel.updateZapFromText() }) {
Text(text = stringResource(R.string.load_from_text))
}
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.zap_split_explainer),
@@ -1123,7 +1128,7 @@ fun FowardZapTo(
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(splitItem.key, showPlayButton = false)
UsernameDisplay(splitItem.key)
Text(
text = String.format("%.0f%%", splitItem.percentage * 100),
maxLines = 1,
@@ -1138,7 +1143,7 @@ fun FowardZapTo(
Slider(
value = splitItem.percentage,
onValueChange = { sliderValue ->
val rounded = (round(sliderValue * 20)) / 20.0f
val rounded = (round(sliderValue * 100)) / 100.0f
postViewModel.updateZapPercentage(index, rounded)
},
modifier = Modifier.weight(1.5f),
@@ -1209,7 +1214,7 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
DisplayLocationObserver(postViewModel)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.geohash_explainer),
@@ -1279,8 +1284,7 @@ fun Notifying(
mentions.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
innerUserState?.user?.let { myUser ->
val tags =
remember(innerUserState) { myUser.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val tags = myUser.info?.tags
Button(
shape = ButtonBorder,
@@ -1435,50 +1439,10 @@ private fun ForwardZapTo(
IconButton(
onClick = { onClick() },
) {
Box(
Modifier
.height(20.dp)
.width(25.dp),
) {
if (!postViewModel.wantsForwardZapTo) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.add_zap_split),
modifier =
Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = MaterialTheme.colorScheme.onBackground,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.cancel_zap_split),
modifier =
Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange,
)
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = null,
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange,
)
}
if (!postViewModel.wantsForwardZapTo) {
ZapSplitIcon(tint = MaterialTheme.colorScheme.onBackground)
} else {
ZapSplitIcon(tint = BitcoinOrange)
}
}
}
@@ -1722,7 +1686,7 @@ fun ImageVideoDescription(
}
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -35,8 +35,8 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -49,12 +49,15 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
@@ -69,10 +72,13 @@ 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
import java.util.UUID
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
@@ -82,12 +88,14 @@ enum class UserSuggestionAnchor {
@Stable
open class NewPostViewModel() : ViewModel() {
var draftTag: String by mutableStateOf(UUID.randomUUID().toString())
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
var requiresNIP17: Boolean = false
var originalNote: Note? = null
var forkedFromNote: Note? = null
var originalNote: Note? by mutableStateOf<Note?>(null)
var forkedFromNote: Note? by mutableStateOf<Note?>(null)
var pTags by mutableStateOf<List<User>?>(null)
var eTags by mutableStateOf<List<Note>?>(null)
@@ -161,8 +169,10 @@ open class NewPostViewModel() : ViewModel() {
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
// NIP17 Wrapped DMs / Group messages
var nip17 by mutableStateOf(false)
val draftTextChanges = Channel<String>(Channel.CONFLATED)
fun lnAddress(): String? {
return account?.userProfile()?.info?.lnAddress()
@@ -182,132 +192,280 @@ open class NewPostViewModel() : ViewModel() {
quote: Note?,
fork: Note?,
version: Note?,
draft: Note?,
) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
originalNote = replyingTo
replyingTo?.let { replyNote ->
if (replyNote.event is BaseTextNoteEvent) {
this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote)
} else {
this.eTags = listOf(replyNote)
}
val noteEvent = draft?.event
val noteAuthor = draft?.author
if (replyNote.event !is CommunityDefinitionEvent) {
replyNote.author?.let { replyUser ->
val currentMentions =
(replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) }
?: emptyList()
if (currentMentions.contains(replyUser)) {
this.pTags = currentMentions
} else {
this.pTags = currentMentions.plus(replyUser)
if (draft != null && noteEvent is DraftEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent) { innerNote ->
if (innerNote != null) {
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag = oldTag
}
loadFromDraft(innerNote, accountViewModel)
}
}
}
}
?: run {
eTags = null
pTags = null
} else {
originalNote = replyingTo
replyingTo?.let { replyNote ->
if (replyNote.event is BaseTextNoteEvent) {
this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote)
} else {
this.eTags = listOf(replyNote)
}
if (replyNote.event !is CommunityDefinitionEvent) {
replyNote.author?.let { replyUser ->
val currentMentions =
(replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) }
?: emptyList()
if (currentMentions.contains(replyUser)) {
this.pTags = currentMentions
} else {
this.pTags = currentMentions.plus(replyUser)
}
}
}
}
?: run {
eTags = null
pTags = null
}
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
it.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
forwardZapTo.addItem(quotedUser)
}
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
forwardZapTo.addItem(accountViewModel.userProfile())
}
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.9f)
}
}
}
fork?.let {
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
urlPreview = findUrlInMessage()
it.event?.isSensitive()?.let {
if (it) wantsToMarkAsSensitive = true
}
it.event?.zapraiserAmount()?.let {
zapRaiserAmount = it
}
it.event?.zapSplitSetup()?.let {
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
it.forEach {
if (!it.isLnAddress) {
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
}
}
}
// Only adds if it is not already set up.
if (forwardZapTo.items.isEmpty()) {
it.author?.let { forkedAuthor ->
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.8f)
}
}
}
it.author?.let {
if (this.pTags == null) {
this.pTags = listOf(it)
} else if (this.pTags?.contains(it) != true) {
this.pTags = listOf(it) + (this.pTags ?: emptyList())
}
}
forkedFromNote = it
} ?: run {
forkedFromNote = null
}
if (!forwardZapTo.items.isEmpty()) {
wantsForwardZapTo = true
}
}
}
private fun loadFromDraft(
draft: Note,
accountViewModel: AccountViewModel,
) {
Log.d("draft", draft.event!!.toJson())
val draftEvent = draft.event ?: return
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
wantsZapraiser = false
zapRaiserAmount = null
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
forwardZapTo = Split()
localfowardZapTo.forEach {
val user = LocalCache.getOrCreateUser(it[1])
val value = it.last().toFloatOrNull() ?: 0f
forwardZapTo.addItem(user, value)
}
forwardZapToEditting = TextFieldValue("")
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
it.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
forwardZapTo.addItem(quotedUser)
}
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
forwardZapTo.addItem(accountViewModel.userProfile())
}
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.9f)
}
}
wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" }
wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" }
val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" }
wantsZapraiser = zapraiser.isNotEmpty()
zapRaiserAmount = null
if (wantsZapraiser) {
zapRaiserAmount = zapraiser.first()[1].toLongOrNull() ?: 0
}
fork?.let {
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
urlPreview = findUrlInMessage()
it.event?.isSensitive()?.let {
if (it) wantsToMarkAsSensitive = true
eTags =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull {
val note = LocalCache.checkGetOrCreateNote(it[1])
note
}
it.event?.zapraiserAmount()?.let {
zapRaiserAmount = it
}
it.event?.zapSplitSetup()?.let {
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
it.forEach {
if (!it.isLnAddress) {
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
}
if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) {
pTags =
draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
}
}
// Only adds if it is not already set up.
if (forwardZapTo.items.isEmpty()) {
it.author?.let { forkedAuthor ->
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.8f)
}
}
}
it.author?.let {
if (this.pTags == null) {
this.pTags = listOf(it)
} else if (this.pTags?.contains(it) != true) {
this.pTags = listOf(it) + (this.pTags ?: emptyList())
}
}
forkedFromNote = it
}
if (!forwardZapTo.items.isEmpty()) {
draftEvent.tags().filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it.get(3) == "fork" }.forEach {
val note = LocalCache.checkGetOrCreateNote(it[1])
forkedFromNote = note
}
originalNote =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
if (originalNote == null) {
originalNote =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
}
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
if (forwardZapTo.items.isNotEmpty()) {
wantsForwardZapTo = true
}
val polls = draftEvent.tags().filter { it.size > 1 && it[0] == "poll_option" }
wantsPoll = polls.isNotEmpty()
polls.forEach {
pollOptions[it[1].toInt()] = it[2]
}
val minMax = draftEvent.tags().filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") }
minMax.forEach {
if (it[0] == "value_maximum") {
valueMaximum = it[1].toInt()
} else if (it[0] == "value_minimum") {
valueMinimum = it[1].toInt()
}
}
wantsProduct = draftEvent.kind() == 30402
title = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "title" }.map { it[1] }?.firstOrNull() ?: "")
price = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "price" }.map { it[1] }?.firstOrNull() ?: "")
category = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "t" }.map { it[1] }?.firstOrNull() ?: "")
locationText = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "location" }.map { it[1] }?.firstOrNull() ?: "")
condition = ClassifiedsEvent.CONDITION.entries.firstOrNull {
it.value == draftEvent.tags().filter { it.size > 1 && it[0] == "condition" }.map { it[1] }.firstOrNull()
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent
draftEvent.subject()?.let {
subject = TextFieldValue()
}
message =
if (draftEvent is PrivateDmEvent) {
val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
toUsers = TextFieldValue("@$recepientNpub")
TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "")
} else {
TextFieldValue(draftEvent.content())
}
requiresNIP17 = draftEvent is ChatMessageEvent
nip17 = draftEvent is ChatMessageEvent
if (draftEvent is ChatMessageEvent) {
toUsers =
TextFieldValue(
draftEvent.recipientsPubKey().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
}
urlPreview = findUrlInMessage()
}
fun sendPost(relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList, null)
accountViewModel?.deleteDraft(draftTag)
cancel()
}
}
suspend fun innerSendPost(relayList: List<Relay>? = null) {
fun sendDraft(relayList: List<Relay>? = null) {
viewModelScope.launch {
sendDraftSync(relayList)
}
}
suspend fun sendDraftSync(relayList: List<Relay>? = null) {
innerSendPost(relayList, draftTag)
}
private suspend fun innerSendPost(
relayList: List<Relay>? = null,
localDraft: String?,
) = withContext(Dispatchers.IO) {
if (accountViewModel == null) {
cancel()
return
return@withContext
}
val tagger =
NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!)
val tagger = NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!)
tagger.run()
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!)
@@ -361,6 +519,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
account?.sendChannelMessage(
@@ -373,6 +532,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is PrivateDmEvent) {
@@ -386,6 +546,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (originalNote?.event is ChatMessageEvent) {
val receivers =
@@ -396,7 +557,7 @@ open class NewPostViewModel() : ViewModel() {
.toSet()
.toList()
account?.sendNIP24PrivateMessage(
account?.sendNIP17PrivateMessage(
message = tagger.message,
toUsers = receivers,
subject = subject.text.ifBlank { null },
@@ -407,10 +568,11 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
account?.sendNIP24PrivateMessage(
if (nip17 || dmUsers.size > 1) {
account?.sendNIP17PrivateMessage(
message = tagger.message,
toUsers = dmUsers.map { it.pubkeyHex },
subject = subject.text.ifBlank { null },
@@ -421,6 +583,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
account?.sendPrivateMessage(
@@ -433,6 +596,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is GitIssueEvent) {
@@ -473,24 +637,26 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.eTags,
tagger.pTags,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash,
message = tagger.message,
replyTo = tagger.eTags,
mentions = tagger.pTags,
pollOptions = pollOptions,
valueMaximum = valueMaximum,
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (wantsProduct) {
account?.sendClassifieds(
@@ -509,6 +675,7 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
// adds markers
@@ -545,11 +712,10 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
}
cancel()
}
fun upload(
@@ -627,6 +793,8 @@ open class NewPostViewModel() : ViewModel() {
toUsers = TextFieldValue("")
subject = TextFieldValue("")
forkedFromNote = null
contentToAddUrl = null
urlPreview = null
isUploadingImage = false
@@ -648,6 +816,9 @@ open class NewPostViewModel() : ViewModel() {
wantsProduct = false
condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
locationText = TextFieldValue("")
title = TextFieldValue("")
category = TextFieldValue("")
price = TextFieldValue("")
wantsForwardZapTo = false
@@ -660,21 +831,29 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionAnchor = null
userSuggestionsMainMessage = null
draftTag = UUID.randomUUID().toString()
NostrSearchEventOrUserDataSource.clear()
}
open fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
}
fun deleteDraft() {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel?.deleteDraft(draftTag)
}
}
open fun findUrlInMessage(): String? {
return RichTextParser().parseValidUrls(message.text).firstOrNull()
}
open fun removeFromReplyList(userToRemove: User) {
pTags = pTags?.filter { it != userToRemove }
}
private fun saveDraft() {
draftTextChanges.trySend("")
}
open fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
@@ -689,7 +868,7 @@ open class NewPostViewModel() : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
} else {
@@ -697,6 +876,8 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
saveDraft()
}
open fun updateToUsers(it: TextFieldValue) {
@@ -712,7 +893,7 @@ open class NewPostViewModel() : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
} else {
@@ -720,10 +901,12 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
saveDraft()
}
open fun updateSubject(it: TextFieldValue) {
subject = it
saveDraft()
}
open fun updateZapForwardTo(it: TextFieldValue) {
@@ -741,6 +924,7 @@ open class NewPostViewModel() : ViewModel() {
compareBy(
{ account?.isFollowing(it) },
{ it.toBestDisplayName() },
{ it.pubkeyHex },
),
)
.reversed()
@@ -768,16 +952,6 @@ open class NewPostViewModel() : ViewModel() {
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.addItem(item)
forwardZapToEditting = TextFieldValue("")
/*
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
forwardZapTo = item
forwardZapToEditting = TextFieldValue(
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)*/
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord =
toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
@@ -795,6 +969,8 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionsMainMessage = null
userSuggestions = emptyList()
}
saveDraft()
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
@@ -861,10 +1037,11 @@ open class NewPostViewModel() : ViewModel() {
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false
nip94attachments = nip94attachments + event
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
message = message.insertUrlAtCursor(imageUrl)
urlPreview = findUrlInMessage()
saveDraft()
}
},
onError = {
@@ -909,6 +1086,7 @@ open class NewPostViewModel() : ViewModel() {
}
urlPreview = findUrlInMessage()
saveDraft()
}
},
onError = {
@@ -929,6 +1107,7 @@ open class NewPostViewModel() : ViewModel() {
locUtil?.let {
location =
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
saveDraft()
}
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
}
@@ -948,10 +1127,13 @@ open class NewPostViewModel() : ViewModel() {
}
fun toggleNIP04And24() {
if (requiresNIP24) {
nip24 = true
if (requiresNIP17) {
nip17 = true
} else {
nip24 = !nip24
nip17 = !nip17
}
if (message.text.isNotBlank()) {
saveDraft()
}
}
@@ -972,6 +1154,7 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
saveDraft()
}
fun updateMaxZapAmountForPoll(textMax: String) {
@@ -991,6 +1174,7 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
saveDraft()
}
fun checkMinMax() {
@@ -1009,6 +1193,72 @@ open class NewPostViewModel() : ViewModel() {
) {
forwardZapTo.updatePercentage(index, sliderValue)
}
fun updateZapFromText() {
viewModelScope.launch(Dispatchers.Default) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.items.any { it.key == taggedUser }) {
forwardZapTo.addItem(taggedUser)
}
}
}
}
fun updateZapRaiserAmount(newAmount: Long?) {
zapRaiserAmount = newAmount
saveDraft()
}
fun removePollOption(optionIndex: Int) {
pollOptions.remove(optionIndex)
saveDraft()
}
fun updatePollOption(
optionIndex: Int,
text: String,
) {
pollOptions[optionIndex] = text
saveDraft()
}
fun toggleMarkAsSensitive() {
wantsToMarkAsSensitive = !wantsToMarkAsSensitive
saveDraft()
}
fun updateTitle(it: TextFieldValue) {
title = it
saveDraft()
}
fun updatePrice(it: TextFieldValue) {
runCatching {
if (it.text.isEmpty()) {
price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
price = it
}
}
saveDraft()
}
fun updateCondition(newCondition: ClassifiedsEvent.CONDITION) {
condition = newCondition
saveDraft()
}
fun updateCategory(value: TextFieldValue) {
category = value
saveDraft()
}
fun updateLocation(it: TextFieldValue) {
locationText = it
saveDraft()
}
}
enum class GeohashPrecision(val digits: Int) {
@@ -68,7 +68,7 @@ class NewUserMetadataViewModel : ViewModel() {
account.userProfile().let {
// userName.value = it.bestUsername() ?: ""
displayName.value = it.bestDisplayName() ?: ""
displayName.value = it.info?.bestName() ?: ""
about.value = it.info?.about ?: ""
picture.value = it.info?.picture ?: ""
banner.value = it.info?.banner ?: ""
@@ -82,7 +82,7 @@ class NewUserMetadataViewModel : ViewModel() {
mastodon.value = ""
// TODO: Validate Telegram input, somehow.
it.info?.latestMetadata?.identityClaims()?.forEach {
it.latestMetadata?.identityClaims()?.forEach {
when (it) {
is TwitterIdentity -> twitter.value = it.toProofUrl()
is GitHubIdentity -> github.value = it.toProofUrl()
@@ -65,9 +65,11 @@ fun NotifyRequestDialog(
TranslatableRichTextViewer(
textContent,
canPreview = true,
quotesLeft = 1,
Modifier.fillMaxWidth(),
EmptyTagList,
background,
textContent,
accountViewModel,
nav,
)
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.relays.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
@@ -78,7 +79,7 @@ fun RelaySelectionDialog(
var relays by remember {
mutableStateOf(
accountViewModel.account.activeWriteRelays().map {
accountViewModel.account.connectToRelays.value.map {
RelayList(
relay = it,
relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url),
@@ -0,0 +1,139 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddDMRelayListDialog(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val postViewModel: DMRelayListViewModel = viewModel()
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Text(stringResource(R.string.dm_relays_title))
SaveButton(
onPost = {
postViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.clear()
onClose()
},
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
Explanation()
DMRelayList(postViewModel, accountViewModel, onClose, nav)
}
}
}
}
@Composable
private fun Explanation() {
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(id = R.string.dm_relays_not_found_editing),
)
Spacer(modifier = StdVertSpacer)
Text(
text = stringResource(id = R.string.dm_relays_not_found_examples),
)
}
}
}
@@ -0,0 +1,139 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddSearchRelayListDialog(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val postViewModel: SearchRelayListViewModel = viewModel()
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Text(stringResource(R.string.search_relays_title))
SaveButton(
onPost = {
postViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.clear()
onClose()
},
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
Explanation()
SearchRelayList(postViewModel, accountViewModel, onClose, nav)
}
}
}
}
@Composable
private fun Explanation() {
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(id = R.string.search_relays_not_found_editing),
)
Spacer(modifier = StdVertSpacer)
Text(
text = stringResource(id = R.string.search_relays_not_found_examples),
)
}
}
}
@@ -0,0 +1,307 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AllRelayListView(
onClose: () -> Unit,
relayToAdd: String = "",
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
val dmViewModel: DMRelayListViewModel = viewModel()
val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle()
val nip65ViewModel: Nip65RelayListViewModel = viewModel()
val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle()
val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle()
val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel()
val privateOutboxFeedState by privateOutboxViewModel.relays.collectAsStateWithLifecycle()
val searchViewModel: SearchRelayListViewModel = viewModel()
val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle()
val localViewModel: LocalRelayListViewModel = viewModel()
val localFeedState by localViewModel.relays.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
kind3ViewModel.load(accountViewModel.account)
dmViewModel.load(accountViewModel.account)
nip65ViewModel.load(accountViewModel.account)
searchViewModel.load(accountViewModel.account)
localViewModel.load(accountViewModel.account)
privateOutboxViewModel.load(accountViewModel.account)
}
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = MinHorzSpacer)
Text(
text = stringResource(R.string.relay_settings),
style = MaterialTheme.typography.titleLarge,
)
SaveButton(
onPost = {
kind3ViewModel.create()
dmViewModel.create()
nip65ViewModel.create()
searchViewModel.create()
localViewModel.create()
privateOutboxViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Row {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
kind3ViewModel.clear()
dmViewModel.clear()
nip65ViewModel.clear()
searchViewModel.clear()
localViewModel.clear()
privateOutboxViewModel.clear()
onClose()
},
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
LazyColumn(contentPadding = FeedPadding) {
item {
SettingsCategory(
stringResource(R.string.public_home_section),
stringResource(R.string.public_home_section_explainer),
Modifier.padding(bottom = 8.dp),
)
}
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringResource(R.string.public_notif_section),
stringResource(R.string.public_notif_section_explainer),
)
}
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringResource(R.string.private_inbox_section),
stringResource(R.string.private_inbox_section_explainer),
)
}
renderDMItems(dmFeedState, dmViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringResource(R.string.private_outbox_section),
stringResource(R.string.private_outbox_section_explainer),
)
}
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, onClose, nav)
item {
SettingsCategoryWithButton(
stringResource(R.string.search_section),
stringResource(R.string.search_section_explainer),
action = {
ResetSearchRelays(searchViewModel)
},
)
}
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringResource(R.string.local_section),
stringResource(R.string.local_section_explainer),
)
}
renderLocalItems(localFeedState, localViewModel, accountViewModel, onClose, nav)
item {
SettingsCategoryWithButton(
stringResource(R.string.kind_3_section),
stringResource(R.string.kind_3_section_description),
action = {
ResetKind3Relays(kind3ViewModel)
},
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, onClose, nav, relayToAdd)
}
}
}
}
}
@Composable
fun ResetKind3Relays(postViewModel: Kind3RelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
Constants.defaultRelays.forEach { postViewModel.addRelay(it) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringResource(R.string.default_relays))
}
}
@Composable
fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it)) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringResource(R.string.default_relays))
}
}
@Composable
fun SettingsCategory(
title: String,
description: String? = null,
modifier: Modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
) {
Column(modifier) {
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
if (description != null) {
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
}
@Composable
fun SettingsCategoryWithButton(
title: String,
description: String? = null,
action: @Composable () -> Unit,
modifier: Modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
) {
Row(modifier, horizontalArrangement = RowColSpacing) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
if (description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
action()
}
}
@@ -0,0 +1,36 @@
/**
* 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.actions.relays
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
@Immutable
data class BasicRelaySetupInfo(
val url: String,
val errorCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@@ -0,0 +1,97 @@
/**
* 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.actions.relays
import androidx.compose.foundation.clickable
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.largeRelayIconModifier
@Composable
fun BasicRelaySetupInfoClickableRow(
item: BasicRelaySetupInfo,
loadProfilePicture: Boolean,
onDelete: (BasicRelaySetupInfo) -> Unit,
onClick: () -> Unit,
accountViewModel: AccountViewModel,
) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
RelayNameAndRemoveButton(
item,
onClick,
onDelete,
ReactionRowHeightChat.fillMaxWidth(),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
) {
RelayStatusRow(
item = item,
modifier = HalfStartPadding.weight(1f),
accountViewModel = accountViewModel,
)
}
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -0,0 +1,111 @@
/**
* 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.actions.relays
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun BasicRelaySetupInfoDialog(
item: BasicRelaySetupInfo,
onDelete: (BasicRelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val context = LocalContext.current
relayInfo?.let {
RelayInformationDialog(
onClose = { relayInfo = null },
relayInfo = it.relayInfo,
relayBriefInfo = it.relayBriefInfo,
accountViewModel = accountViewModel,
nav = nav,
)
}
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
BasicRelaySetupInfoClickableRow(
item = item,
loadProfilePicture = automaticallyShowProfilePicture,
onDelete = onDelete,
accountViewModel = accountViewModel,
onClick = {
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
},
onError = { url, errorCode, exceptionMessage ->
val msg =
when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
}
accountViewModel.toast(
context.getString(R.string.unable_to_download_relay_document),
msg,
)
},
)
},
)
}
@@ -0,0 +1,121 @@
/**
* 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.actions.relays
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
abstract class BasicRelaySetupInfoModel : ViewModel() {
lateinit var account: Account
private val _relays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
var hasModified = false
fun load(account: Account) {
this.account = account
clear()
loadRelayDocuments()
}
abstract fun getRelayList(): List<String>?
abstract fun saveRelayList(urlList: List<String>)
fun create() {
if (hasModified) {
viewModelScope.launch(Dispatchers.IO) {
saveRelayList(_relays.value.map { it.url })
clear()
}
}
}
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
}
}
fun clear() {
var hasModified = false
_relays.update {
val relayList = getRelayList() ?: emptyList()
relayList.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
BasicRelaySetupInfo(
relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}
}
fun addRelay(relay: BasicRelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update { it.plus(relay) }
hasModified = true
}
fun deleteRelay(relay: BasicRelaySetupInfo) {
_relays.update { it.minus(relay) }
hasModified = true
}
fun deleteAll() {
_relays.update { relays -> emptyList() }
hasModified = true
}
fun togglePaidRelay(
relay: BasicRelaySetupInfo,
paid: Boolean,
) {
_relays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
}
@@ -0,0 +1,39 @@
/**
* 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.actions.relays
fun countToHumanReadableBytes(counter: Int) =
when {
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)} GB"
counter >= 1000000 -> "${Math.round(counter / 1000000f)} MB"
counter >= 1000 -> "${Math.round(counter / 1000f)} KB"
else -> "$counter"
}
fun countToHumanReadable(
counter: Int,
str: String,
) = when {
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)}G $str"
counter >= 1000000 -> "${Math.round(counter / 1000000f)}M $str"
counter >= 1000 -> "${Math.round(counter / 1000f)}K $str"
else -> "$counter $str"
}
@@ -0,0 +1,77 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun DMRelayList(
postViewModel: DMRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderDMItems(feedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderDMItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: DMRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addRelay(it) }
}
}
@@ -0,0 +1,31 @@
/**
* 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.actions.relays
class DMRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.getDMRelayList()?.relays()
}
override fun saveRelayList(urlList: List<String>) {
account.saveDMRelayList(urlList)
}
}
@@ -18,13 +18,12 @@
* 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.actions
package com.vitorpamplona.amethyst.ui.actions.relays
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -32,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
@@ -40,23 +40,17 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -72,214 +66,85 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Font14SP
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.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import kotlinx.coroutines.launch
import java.lang.Math.round
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewRelayListView(
onClose: () -> Unit,
fun Kind3RelayListView(
feedState: List<RelaySetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
relayToAdd: String = "",
onClose: () -> Unit,
nav: (String) -> Unit,
relayToAdd: String,
) {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Button(
onClick = {
postViewModel.deleteAll()
defaultRelays.forEach { postViewModel.addRelay(it) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringResource(R.string.default_relays))
}
SaveButton(
onPost = {
postViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.clear()
onClose()
},
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
ServerConfig(
item,
onToggleDownload = { postViewModel.toggleDownload(it) },
onToggleUpload = { postViewModel.toggleUpload(it) },
onToggleFollows = { postViewModel.toggleFollows(it) },
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
}
}
Spacer(modifier = StdVertSpacer)
EditableServerConfig(relayToAdd) { postViewModel.addRelay(it) }
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(contentPadding = FeedPadding) {
renderKind3Items(feedState, postViewModel, accountViewModel, onClose, nav, relayToAdd)
}
}
}
@Composable
fun ServerConfigHeader() {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.relay_address),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(30.dp))
Text(
text = stringResource(R.string.bytes),
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(id = R.string.bytes),
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(R.string.errors),
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(R.string.spam),
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = Modifier.size(2.dp))
}
}
fun LazyListScope.renderKind3Items(
feedState: List<RelaySetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
relayToAdd: String,
) {
itemsIndexed(feedState, key = { _, item -> "kind3" + item.url }) { index, item ->
LoadRelayInfo(
item,
onToggleDownload = { postViewModel.toggleDownload(it) },
onToggleUpload = { postViewModel.toggleUpload(it) },
onToggleFollows = { postViewModel.toggleFollows(it) },
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
HorizontalDivider(thickness = DividerThickness)
item {
Spacer(modifier = StdVertSpacer)
Kind3RelayEditBox(relayToAdd) { postViewModel.addRelay(it) }
}
}
@Preview
@Composable
fun ServerConfigPreview() {
ServerConfigClickableLine(
ClickableRelayItem(
loadProfilePicture = true,
item =
RelaySetupInfo(
@@ -306,7 +171,7 @@ fun ServerConfigPreview() {
}
@Composable
fun ServerConfig(
fun LoadRelayInfo(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
@@ -337,7 +202,7 @@ fun ServerConfig(
accountViewModel.settings.showProfilePictures.value
}
ServerConfigClickableLine(
ClickableRelayItem(
item = item,
loadProfilePicture = automaticallyShowProfilePicture,
onToggleDownload = onToggleDownload,
@@ -351,7 +216,9 @@ fun ServerConfig(
onClick = {
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = { relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) },
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
},
onError = { url, errorCode, exceptionMessage ->
val msg =
when (errorCode) {
@@ -361,18 +228,21 @@ fun ServerConfig(
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
context.getString(
R.string.relay_information_document_error_assemble_url,
@@ -392,7 +262,7 @@ fun ServerConfig(
}
@Composable
fun ServerConfigClickableLine(
fun ClickableRelayItem(
item: RelaySetupInfo,
loadProfilePicture: Boolean,
onToggleDownload: (RelaySetupInfo) -> Unit,
@@ -433,13 +303,12 @@ fun ServerConfigClickableLine(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
) {
RenderActiveToggles(
ActiveToggles(
item = item,
onToggleFollows = onToggleFollows,
onTogglePrivateDMs = onTogglePrivateDMs,
onTogglePublicChats = onTogglePublicChats,
onToggleGlobal = onToggleGlobal,
onToggleSearch = onToggleSearch,
)
}
@@ -447,7 +316,7 @@ fun ServerConfigClickableLine(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
) {
RenderStatusRow(
StatusRow(
item = item,
onToggleDownload = onToggleDownload,
onToggleUpload = onToggleUpload,
@@ -463,7 +332,7 @@ fun ServerConfigClickableLine(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderStatusRow(
private fun StatusRow(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
@@ -615,13 +484,12 @@ private fun RenderStatusRow(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderActiveToggles(
private fun ActiveToggles(
item: RelaySetupInfo,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -767,40 +635,6 @@ private fun RenderActiveToggles(
},
)
}
IconButton(
modifier = Size30Modifier,
onClick = { onToggleSearch(item) },
) {
Icon(
imageVector = Icons.Default.Search,
stringResource(R.string.search_feed),
modifier =
Modifier.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onToggleSearch(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.search_feed),
Toast.LENGTH_SHORT,
)
.show()
}
},
),
tint =
if (item.feedTypes.contains(FeedType.SEARCH)) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f,
)
},
)
}
}
@Composable
@@ -844,7 +678,7 @@ private fun FirstLine(
}
@Composable
fun EditableServerConfig(
fun Kind3RelayEditBox(
relayToAdd: String,
onNewRelay: (RelaySetupInfo) -> Unit,
) {
@@ -899,10 +733,15 @@ fun EditableServerConfig(
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS =
if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
val addedWSS = RelayUrlFormatter.normalize(url)
onNewRelay(
RelaySetupInfo(
addedWSS,
read,
write,
feedTypes = FeedType.values().toSet(),
),
)
url = ""
write = true
read = true
@@ -923,21 +762,3 @@ fun EditableServerConfig(
}
}
}
fun countToHumanReadableBytes(counter: Int) =
when {
counter >= 1000000000 -> "${round(counter / 1000000000f)} GB"
counter >= 1000000 -> "${round(counter / 1000000f)} MB"
counter >= 1000 -> "${round(counter / 1000f)} KB"
else -> "$counter"
}
fun countToHumanReadable(
counter: Int,
str: String,
) = when {
counter >= 1000000000 -> "${round(counter / 1000000000f)}G $str"
counter >= 1000000 -> "${round(counter / 1000000f)}M $str"
counter >= 1000 -> "${round(counter / 1000f)}K $str"
else -> "$counter $str"
}
@@ -18,7 +18,7 @@
* 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.actions
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.quartz.events.ContactListEvent
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,12 +35,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NewRelayListViewModel : ViewModel() {
class Kind3RelayListViewModel : ViewModel() {
private lateinit var account: Account
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
var hasModified = false
fun load(account: Account) {
this.account = account
clear()
@@ -49,9 +50,9 @@ class NewRelayListViewModel : ViewModel() {
}
fun create() {
relays.let {
if (hasModified) {
viewModelScope.launch(Dispatchers.IO) {
account.saveRelayList(it.value)
account.saveKind3RelayList(relays.value)
clear()
}
}
@@ -72,35 +73,11 @@ class NewRelayListViewModel : ViewModel() {
}
fun clear() {
hasModified = false
_relays.update {
var relayFile = account.userProfile().latestContactList?.relays()
if (relayFile != null) {
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
val needsSearchRelay =
relayFile.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } &&
relayFile.none {
account.localRelays
.filter { localRelay -> localRelay.url == it.key }
.firstOrNull()
?.feedTypes
?.contains(FeedType.SEARCH)
?: false
}
if (needsSearchRelay) {
relayFile =
relayFile +
Constants.forcedRelayForSearch.map {
Pair(
it.url,
ContactListEvent.ReadWrite(it.read, it.write),
)
}
}
relayFile
.map {
val liveRelay = RelayPool.getRelay(it.key)
@@ -166,47 +143,58 @@ class NewRelayListViewModel : ViewModel() {
if (relays.value.any { it.url == relay.url }) return
_relays.update { it.plus(relay) }
hasModified = true
}
fun deleteRelay(relay: RelaySetupInfo) {
_relays.update { it.minus(relay) }
hasModified = true
}
fun deleteAll() {
_relays.update { relays -> emptyList() }
hasModified = true
}
fun toggleDownload(relay: RelaySetupInfo) {
_relays.update { it.updated(relay, relay.copy(read = !relay.read)) }
hasModified = true
}
fun toggleUpload(relay: RelaySetupInfo) {
_relays.update { it.updated(relay, relay.copy(write = !relay.write)) }
hasModified = true
}
fun toggleFollows(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleMessages(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun togglePublicChats(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleGlobal(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleSearch(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun togglePaidRelay(
@@ -0,0 +1,77 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun LocalRelayList(
postViewModel: LocalRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderLocalItems(feedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderLocalItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: LocalRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addRelay(it) }
}
}
@@ -0,0 +1,31 @@
/**
* 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.actions.relays
class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.localRelayServers.toList()
}
override fun saveRelayList(urlList: List<String>) {
account.updateLocalRelayServers(urlList.toSet())
}
}
@@ -0,0 +1,103 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun Nip65RelayList(
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val homeFeedState by postViewModel.homeRelays.collectAsStateWithLifecycle()
val notifFeedState by postViewModel.notificationRelays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderNip65HomeItems(homeFeedState, postViewModel, accountViewModel, onClose, nav)
renderNip65NotifItems(notifFeedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderNip65HomeItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteHomeRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addHomeRelay(it) }
}
}
fun LazyListScope.renderNip65NotifItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteNotifRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addNotifRelay(it) }
}
}
@@ -0,0 +1,192 @@
/**
* 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.actions.relays
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class Nip65RelayListViewModel : ViewModel() {
private lateinit var account: Account
private val _homeRelays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
val homeRelays = _homeRelays.asStateFlow()
private val _notificationRelays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
val notificationRelays = _notificationRelays.asStateFlow()
var hasModified = false
fun load(account: Account) {
this.account = account
clear()
loadRelayDocuments()
}
fun create() {
if (hasModified) {
viewModelScope.launch(Dispatchers.IO) {
val writes = _homeRelays.value.map { it.url }.toSet()
val reads = _notificationRelays.value.map { it.url }.toSet()
val urls = writes.union(reads)
account.sendNip65RelayList(
urls.map {
val type =
if (writes.contains(it) && reads.contains(it)) {
AdvertisedRelayListEvent.AdvertisedRelayType.BOTH
} else if (writes.contains(it)) {
AdvertisedRelayListEvent.AdvertisedRelayType.WRITE
} else {
AdvertisedRelayListEvent.AdvertisedRelayType.READ
}
AdvertisedRelayListEvent.AdvertisedRelayInfo(it, type)
},
)
clear()
}
}
}
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_homeRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
_notificationRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
}
}
fun clear() {
hasModified = false
_homeRelays.update {
val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList()
relayList.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
BasicRelaySetupInfo(
relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}
_notificationRelays.update {
val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList()
relayList.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
BasicRelaySetupInfo(
relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}
}
fun addHomeRelay(relay: BasicRelaySetupInfo) {
if (_homeRelays.value.any { it.url == relay.url }) return
_homeRelays.update { it.plus(relay) }
hasModified = true
}
fun deleteHomeRelay(relay: BasicRelaySetupInfo) {
_homeRelays.update { it.minus(relay) }
hasModified = true
}
fun deleteHomeAll() {
_homeRelays.update { relays -> emptyList() }
hasModified = true
}
fun toggleHomePaidRelay(
relay: BasicRelaySetupInfo,
paid: Boolean,
) {
_homeRelays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
fun addNotifRelay(relay: BasicRelaySetupInfo) {
if (_notificationRelays.value.any { it.url == relay.url }) return
_notificationRelays.update { it.plus(relay) }
hasModified = true
}
fun deleteNotifRelay(relay: BasicRelaySetupInfo) {
_notificationRelays.update { it.minus(relay) }
hasModified = true
}
fun deleteNotifAll() {
_notificationRelays.update { relays -> emptyList() }
hasModified = true
}
fun toggleNotifPaidRelay(
relay: BasicRelaySetupInfo,
paid: Boolean,
) {
_notificationRelays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
}
@@ -0,0 +1,77 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun PrivateOutboxRelayList(
postViewModel: PrivateOutboxRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderPrivateOutboxItems(feedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderPrivateOutboxItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: PrivateOutboxRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Outbox" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addRelay(it) }
}
}
@@ -0,0 +1,31 @@
/**
* 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.actions.relays
class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.getPrivateOutboxRelayList()?.relays()
}
override fun saveRelayList(urlList: List<String>) {
account.savePrivateOutboxRelayList(urlList)
}
}
@@ -18,7 +18,7 @@
* 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.actions
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
@@ -48,6 +48,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.note.LoadUser
@@ -294,7 +295,6 @@ private fun DisplayOwnerInformation(
UserCompose(
baseUser = it,
accountViewModel = accountViewModel,
showDiviser = false,
nav = nav,
)
}
@@ -0,0 +1,88 @@
/**
* 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.actions.relays
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
@Composable
fun RelayNameAndRemoveButton(
item: BasicRelaySetupInfo,
onClick: () -> Unit,
onDelete: (BasicRelaySetupInfo) -> Unit,
modifier: Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
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,
)
}
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) },
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringResource(id = R.string.remove),
modifier =
Modifier
.padding(start = 10.dp)
.size(15.dp),
tint = WarningColor,
)
}
}
}
@@ -0,0 +1,180 @@
/**
* 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.actions.relays
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.warningColor
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun RelayStatusRow(
item: BasicRelaySetupInfo,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.read_from_relay),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.read_from_relay,
R.string.read_from_relay_description,
)
},
),
tint = MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = countToHumanReadableBytes(item.downloadCountInBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.Upload,
stringResource(R.string.write_to_relay),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
accountViewModel.toast(
R.string.write_to_relay,
R.string.write_to_relay_description,
)
},
),
tint = MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = countToHumanReadableBytes(item.uploadCountInBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.SyncProblem,
stringResource(R.string.errors),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.errors,
R.string.errors_description,
)
},
),
tint =
if (item.errorCount > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
},
)
Text(
text = countToHumanReadable(item.errorCount, "errors"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.DeleteSweep,
stringResource(R.string.spam),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.spam,
R.string.spam_description,
)
scope.launch {
Toast.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT,
)
.show()
}
},
),
tint =
if (item.spamCount > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
},
)
Text(
text = countToHumanReadable(item.spamCount, "spam"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
}
@@ -0,0 +1,87 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
@Composable
fun RelayUrlEditField(onNewRelay: (BasicRelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf("") }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.add_a_relay)) },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
placeholder = {
Text(
text = "server.com",
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
},
singleLine = true,
)
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
val addedWSS = RelayUrlFormatter.normalize(url)
onNewRelay(BasicRelaySetupInfo(addedWSS))
url = ""
}
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor =
if (url.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.placeholderText
},
),
) {
Text(text = stringResource(id = R.string.add), color = Color.White)
}
}
}
@@ -0,0 +1,77 @@
/**
* 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.actions.relays
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun SearchRelayList(
postViewModel: SearchRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderSearchItems(feedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderSearchItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: SearchRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Search" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addRelay(it) }
}
}
@@ -0,0 +1,31 @@
/**
* 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.actions.relays
class SearchRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.getSearchRelayList()?.relays()
}
override fun saveRelayList(urlList: List<String>) {
account.saveSearchRelayList(urlList)
}
}
@@ -35,8 +35,8 @@ 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.Divider
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,7 +53,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -65,6 +64,8 @@ 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
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.service.CachedCashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
@@ -76,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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
@@ -181,7 +183,7 @@ fun CashuPreview(
.padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.cashu),
imageVector = CustomHashTagIcons.Cashu,
null,
modifier = Size20Modifier,
tint = Color.Unspecified,
@@ -195,7 +197,7 @@ fun CashuPreview(
)
}
Divider()
HorizontalDivider(thickness = DividerThickness)
Text(
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
@@ -319,7 +321,7 @@ fun CashuPreviewNew(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(R.drawable.cashu),
imageVector = CustomHashTagIcons.Cashu,
null,
modifier = Modifier.size(13.dp),
tint = Color.Unspecified,
@@ -26,13 +26,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ClickableNoteTag(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val route = routeFor(baseNote, accountViewModel.userProfile())
ClickableText(
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
onClick = { nav("Note/${baseNote.idHex}") },
@@ -65,10 +65,10 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -119,7 +119,7 @@ fun LoadOrCreateNote(
@Composable
private fun LoadAndDisplayEvent(
event: Event,
additionalChars: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -141,7 +141,7 @@ private fun LoadAndDisplayEvent(
private fun DisplayEvent(
hex: HexKey,
kind: Int?,
additionalChars: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -164,7 +164,7 @@ private fun DisplayNoteLink(
it: Note,
hex: HexKey,
kind: Int?,
addedCharts: String,
addedCharts: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -218,7 +218,7 @@ private fun DisplayNoteLink(
@Composable
private fun DisplayAddress(
nip19: Nip19Bech32.NAddress,
additionalChars: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -245,16 +245,22 @@ private fun DisplayAddress(
}
if (noteBase == null) {
Text(
remember { "@${nip19.atag}$additionalChars" },
)
if (additionalChars != null) {
Text(
remember { "@${nip19.atag}$additionalChars" },
)
} else {
Text(
remember { "@${nip19.atag}" },
)
}
}
}
@Composable
private fun DisplayUser(
public fun DisplayUser(
userHex: HexKey,
additionalChars: String,
additionalChars: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -274,42 +280,34 @@ private fun DisplayUser(
userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) }
if (userBase == null) {
Text(
remember { "@${userHex}$additionalChars" },
)
if (additionalChars != null) {
Text(
remember { "@${userHex}$additionalChars" },
)
} else {
Text(
remember { "@$userHex" },
)
}
}
}
@Composable
private fun RenderUserAsClickableText(
baseUser: User,
additionalChars: String,
additionalChars: String?,
nav: (String) -> Unit,
) {
val userState by baseUser.live().metadata.observeAsState()
val route = remember { "User/${baseUser.pubkeyHex}" }
val userState by baseUser.live().userMetadataInfo.observeAsState()
val userDisplayName by
remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } }
val userTags by
remember(userState) {
derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
}
userDisplayName?.let {
CreateClickableTextWithEmoji(
clickablePart = it,
maxLines = 1,
route = route,
nav = nav,
tags = userTags,
)
additionalChars.ifBlank { null }?.let {
Text(text = it, maxLines = 1)
}
}
CreateClickableTextWithEmoji(
clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()),
suffix = additionalChars?.ifBlank { null },
maxLines = 1,
route = "User/${baseUser.pubkeyHex}",
nav = nav,
tags = userState?.tags ?: EmptyTagList,
)
}
@Composable
@@ -587,6 +585,7 @@ fun CreateClickableTextWithEmoji(
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String? = null,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
@@ -599,9 +598,16 @@ fun CreateClickableTextWithEmoji(
text = clickablePart,
tags = tags,
onRegularText = {
CreateClickableText(it, null, maxLines, overrideColor, fontWeight, fontSize, route, nav)
CreateClickableText(it, suffix, maxLines, overrideColor, fontWeight, fontSize, route, nav)
},
onEmojiText = {
val nonClickablePartStyle =
SpanStyle(
fontSize = fontSize,
color = overrideColor ?: MaterialTheme.colorScheme.onBackground,
fontWeight = fontWeight,
)
val clickablePartStyle =
SpanStyle(
fontSize = fontSize,
@@ -613,6 +619,8 @@ fun CreateClickableTextWithEmoji(
it,
maxLines,
clickablePartStyle,
suffix,
nonClickablePartStyle,
) {
nav(route)
}
@@ -625,6 +633,8 @@ fun ClickableInLineIconRenderer(
wordsInOrder: ImmutableList<Nip30CustomEmoji.Renderable>,
maxLines: Int = Int.MAX_VALUE,
style: SpanStyle,
suffix: String? = null,
nonClickableStype: SpanStyle? = null,
onClick: (Int) -> Unit,
) {
val placeholderSize =
@@ -652,7 +662,10 @@ fun ClickableInLineIconRenderer(
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp),
modifier =
Modifier
.fillMaxSize()
.padding(1.dp),
)
},
)
@@ -675,6 +688,12 @@ fun ClickableInLineIconRenderer(
}
}
}
if (suffix != null && nonClickableStype != null) {
withStyle(nonClickableStype) {
append(suffix)
}
}
}
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -728,7 +747,10 @@ fun InLineIconRenderer(
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp),
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 0.dp),
)
},
)

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