Compare commits

...
194 Commits
Author SHA1 Message Date
Vitor Pamplona c128e43fe5 v0.89.4 2024-07-19 17:10:22 -04:00
Vitor Pamplona f9ddf76686 action bar fix 2024-07-19 17:09:56 -04:00
Vitor Pamplona b6cb839fe7 v0.89.3 2024-07-19 16:44:30 -04:00
Vitor Pamplona 2b6a48f391 Fixes issue with super large follow lists. 2024-07-19 16:40:46 -04:00
Vitor Pamplona d1da863d3c Making sure an empty relay list uses the default list from Amethyst 2024-07-19 16:14:14 -04:00
Vitor Pamplona 15ab955a7d Fixes alt of NIP-96 server lists 2024-07-19 11:55:46 -04:00
Vitor Pamplona 9176151971 Fixes auth infinite loop with nostr.wine 2024-07-19 10:45:06 -04:00
Vitor Pamplona eba5ecfd91 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-19 09:47:04 -04:00
Vitor Pamplona 3e43a75acd Fixes the keyboard overriding parts of the screen 2024-07-19 09:46:58 -04:00
Vitor PamplonaandGitHub 60ba7786a0 Merge pull request #984 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-18 10:54:01 -04:00
Crowdin Bot 1773ad5a88 New Crowdin translations by GitHub Action 2024-07-18 14:30:23 +00:00
Vitor Pamplona 26faa2bc69 Migrating to edge to edge in preparation for Android 15 2024-07-18 10:28:19 -04:00
Vitor Pamplona 75c7d40f4e v0.89.2 2024-07-17 19:34:56 -04:00
Vitor Pamplona aa4a3fe9af Doesn't show people lists that have 0 people in it. 2024-07-17 19:33:47 -04:00
Vitor Pamplona a32f907d6c Fixes incorrectly marking EOSEs for addressables 2024-07-17 19:26:46 -04:00
Vitor Pamplona d30e613b17 Fixes TopNav people lists not loading 2024-07-17 17:24:06 -04:00
Vitor PamplonaandGitHub 0477f390c2 Merge pull request #983 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-17 16:54:43 -04:00
Crowdin Bot 15e5e2834a New Crowdin translations by GitHub Action 2024-07-17 20:39:46 +00:00
Vitor Pamplona b1399d1c13 Separates init and restart from observables. 2024-07-17 16:35:48 -04:00
Vitor Pamplona 45446b4125 Fixes bug on not updating observablesByKindAndAuthor 2024-07-17 16:35:03 -04:00
Vitor PamplonaandGitHub 094d7e71e2 Merge pull request #982 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-17 09:16:29 -04:00
Crowdin Bot 75423eaaab New Crowdin translations by GitHub Action 2024-07-17 13:13:57 +00:00
Vitor Pamplona 5e42fa5346 Adds additional logs for the size of observables in the Local Cache 2024-07-17 09:09:38 -04:00
Vitor Pamplona bc6d5060cb Moves frame dropping logs from 20ms to 33ms give than many videos make the UI run at 30fps 2024-07-17 09:09:19 -04:00
Vitor Pamplona e578327c5b Fixes the inability to click on the relay tab on User Profiles 2024-07-17 09:08:41 -04:00
Vitor Pamplona ce839ed6d9 v0.89.1 2024-07-16 19:23:37 -04:00
Vitor Pamplona 197484dc1b Removes unnecessary id that was causing crashes. 2024-07-16 19:23:26 -04:00
Vitor Pamplona 60e8ae7b9a v0.89.0 2024-07-16 18:14:04 -04:00
Vitor Pamplona 64ae32d005 Logs when the main looper calls an amethyst handler 2024-07-16 14:54:58 -04:00
Vitor Pamplona 87b8e18f1c Improves logging message for dropped frames 2024-07-16 11:47:41 -04:00
Vitor Pamplona 47a9a6cc4e no message 2024-07-16 09:49:30 -04:00
Vitor Pamplona 1a313a9f78 Adds performance monitors for framedrops in benchmark mode 2024-07-16 09:42:17 -04:00
Vitor PamplonaandGitHub 3554350ac1 Merge pull request #980 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-16 09:10:47 -04:00
Crowdin Bot e2e912c81f New Crowdin translations by GitHub Action 2024-07-16 12:48:38 +00:00
Vitor Pamplona fa71161996 Corrects the name of the live data on Local Cache. 2024-07-16 08:42:05 -04:00
Vitor Pamplona 8de4d461d4 Reduces interruptions to the main thread 2024-07-16 08:41:48 -04:00
Vitor Pamplona f1e3f6a592 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-15 18:16:43 -04:00
Vitor Pamplona 234ef679c8 updates mockk 2024-07-15 18:16:32 -04:00
Vitor PamplonaandGitHub 072000254f Merge pull request #979 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-15 18:08:57 -04:00
Crowdin Bot 34321af07a New Crowdin translations by GitHub Action 2024-07-15 21:57:29 +00:00
Vitor Pamplona 7da559a0c9 Fixes tests 2024-07-15 17:55:46 -04:00
Vitor Pamplona 0564a7e1f1 Adds dynamic relay pool recommendations to the relay list. 2024-07-15 17:34:08 -04:00
Vitor Pamplona 347b16ee8b Fixes the post button disappeering when the Relay Settings top label is too large 2024-07-15 15:34:08 -04:00
Vitor Pamplona edb834db38 Fixes a bug on clicking the user profile but loading the wrong one 2024-07-15 15:32:59 -04:00
Vitor PamplonaandGitHub 97d0cf56a7 Merge pull request #978 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-12 14:00:38 -04:00
Crowdin Bot ad83d362da New Crowdin translations by GitHub Action 2024-07-12 17:58:49 +00:00
Vitor Pamplona f15f6df2ae avoids processing anything else than src for spotless linting 2024-07-12 13:55:45 -04:00
Vitor Pamplona 567a86c8a4 Moving compiler to 12GB of ram. 2024-07-12 09:56:46 -04:00
Vitor Pamplona 16c82ed69c Updates firebase dependencies 2024-07-12 08:55:27 -04:00
Vitor Pamplona a543ee54dc Moves AGP to 8.5.1 2024-07-11 18:27:04 -04:00
Vitor PamplonaandGitHub 128831109a Merge pull request #977 from believethehype/gallery_alternative_version
GalleryEntryEvent: add relay hint to etag
2024-07-11 12:05:19 -04:00
Vitor Pamplona 9641940bd9 Updates relays that are sending old versions of replaceables or events that have been already deleted. 2024-07-11 11:36:59 -04:00
Vitor Pamplona ad449ba239 Activates the top nav list building when deletion events are received as well 2024-07-11 11:34:40 -04:00
Vitor Pamplona 31e2fcdc21 Request updates from relays when showing the People Lists in the top bar. 2024-07-11 11:33:31 -04:00
Vitor Pamplona e28b385038 Removes overloading ambiguity 2024-07-11 10:26:59 -04:00
Vitor PamplonaandGitHub 8d9ecb2054 Merge pull request #960 from greenart7c3/base64
Convert base64 text to images/gifs
2024-07-11 10:26:15 -04:00
greenart7c3andGitHub c57c190344 Merge branch 'main' into base64 2024-07-11 10:42:16 -03:00
Believethehype 7dd319eb25 remove print 2024-07-11 10:40:39 +02:00
Believethehype b4a059c742 Merge branch 'gallery_alternative_version' of https://github.com/believethehype/amethyst into gallery_alternative_version 2024-07-11 10:08:28 +02:00
Believethehype 43d140175c Merge remote-tracking branch 'upstream/main' into gallery_alternative_version 2024-07-11 08:19:29 +02:00
Vitor Pamplona 1a70a02b44 Fixes scoping issues with flattenMerge 2024-07-10 16:04:39 -04:00
believethehypeandGitHub a476298c07 Merge branch 'main' into gallery_alternative_version 2024-07-10 21:58:29 +02:00
Believethehype a0a7f28304 GalleryEntryEvent: add relay hint to etag 2024-07-10 21:53:25 +02:00
Vitor PamplonaandGitHub 17109e7406 Merge pull request #973 from believethehype/gallery_alternative_version
Profile Gallery
2024-07-10 13:37:14 -04:00
believethehypeandGitHub c50343cd04 Merge branch 'main' into gallery_alternative_version 2024-07-10 19:35:30 +02:00
Vitor Pamplona 8feac270f4 Removes unnecessary logs. 2024-07-09 18:00:25 -04:00
Vitor Pamplona c7061066e9 Adds an outbox cache to the Relay class in order to resend events after authentication. 2024-07-09 17:30:16 -04:00
Vitor Pamplona 217870244b No need for material dependencies anymore. 2024-07-09 11:10:34 -04:00
Vitor Pamplona 6fd2282818 increasing memory for kotlin build 2024-07-09 10:55:02 -04:00
Vitor Pamplona b360c3292f removes unnecessary minification for benchmark's debug flavor 2024-07-09 10:45:51 -04:00
Vitor Pamplona 2a9e1bb08c solves build slowdown on spotless dependencies. 2024-07-09 10:42:50 -04:00
Vitor Pamplona aa4e6c051e formatting 2024-07-09 10:42:29 -04:00
Vitor Pamplona f6b947430a Removes the bugfix for reproducible builds since it has been fixed. 2024-07-09 10:42:22 -04:00
Vitor Pamplona 4521317779 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-09 09:30:07 -04:00
Vitor PamplonaandGitHub 25ba353488 Merge pull request #974 from KotlinGeekDev/fix-poll-options-behavior
Fix poll options behavior
2024-07-09 09:29:27 -04:00
Vitor Pamplona 2074b34ec3 Trying maxMetaspaceSize to see if Github Actions can build it. 2024-07-08 13:55:27 -04:00
Vitor PamplonaandGitHub f63731c9b6 Merge pull request #976 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-08 13:50:13 -04:00
Crowdin Bot fc057f2b29 New Crowdin translations by GitHub Action 2024-07-08 17:34:57 +00:00
Vitor Pamplona af4dfbf965 Making sure posts have content to appear in the feed. 2024-07-08 13:33:16 -04:00
Vitor Pamplona 63c5098558 Pausing discovery top nav list watcher. 2024-07-08 13:33:01 -04:00
Believethehype dcad12f836 load older notes in feed 2024-07-07 22:01:16 +02:00
KotlinGeekDev 08e84b056e Some cleanup. 2024-07-07 00:06:44 +01:00
KotlinGeekDev f489deb808 Implement custom element removal with reordering. Works nicely, from observation. 2024-07-06 20:57:06 +01:00
Believethehype 511a7030b8 use alternative gallery using 1163 events instead of 10011 lists.
(This might still need some tuning but works in general)
2024-07-06 00:11:35 +02:00
Believethehype 06a1862d17 add relay info 2024-07-05 13:35:36 +02:00
greenart7c3 b730b8c6f6 Fix linter 2024-07-05 06:56:04 -03:00
greenart7c3andGitHub 21a45844df Merge branch 'main' into base64 2024-07-05 06:35:52 -03:00
greenart7c3 2480b43778 Decode in the io thread 2024-07-05 06:31:58 -03:00
Believethehype ad6dcfc030 use url tag, add relay hint 2024-07-05 08:29:49 +02:00
Believethehype c81324843c move to G Tag for Gallery entries 2024-07-04 20:41:14 +02:00
Believethehype a55da5f62e linting 2024-07-03 23:28:15 +02:00
Believethehype 9b26bdac5c move GalleryUrl to GalleryListEvent, merge recent changes 2024-07-03 23:11:59 +02:00
Believethehype 4bda6fa0cc Merge remote-tracking branch 'upstream/main' into gallery 2024-07-03 23:03:13 +02:00
Vitor Pamplona 1eaa22fe51 increasing gradle's memory to see if CI passes. 2024-07-03 14:37:59 -04:00
Vitor Pamplona 8e212bbb55 changing JVM of actions to zulu to see if java/kotlin compilation errors improve. 2024-07-03 14:16:16 -04:00
Vitor Pamplona fdf0b69e5d Lint formatting 2024-07-03 13:49:57 -04:00
Vitor Pamplona 4b5ab8eb35 Removes unnecessary dependencies for ammolite 2024-07-03 13:49:43 -04:00
Vitor Pamplona 349d6cfc22 Migrates sdk requirements to the version catalog. 2024-07-03 13:49:18 -04:00
Vitor Pamplona 33b8267de7 Fixes linting 3 2024-07-03 11:35:39 -04:00
Vitor Pamplona 713bd59435 Fixes Linting 2 2024-07-03 11:33:38 -04:00
Vitor Pamplona 8c34394d86 Fixes linting problems 2024-07-03 11:21:22 -04:00
Vitor Pamplona 6e1de6fd26 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-03 11:06:10 -04:00
Vitor Pamplona 8d88efd27b Migrating Lint to 1.3.1 2024-07-03 11:06:02 -04:00
Vitor PamplonaandGitHub 6aeaeea5d1 Merge pull request #970 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-03 11:03:30 -04:00
Crowdin Bot 3153296161 New Crowdin translations by GitHub Action 2024-07-03 15:02:50 +00:00
Vitor Pamplona 692f7c4270 Upgrades to Kotlin 2.0.0 2024-07-03 11:00:45 -04:00
believethehypeandGitHub cc8d73e119 Merge branch 'main' into gallery 2024-07-03 13:35:30 +02:00
Vitor PamplonaandGitHub 27c2c71879 Merge pull request #967 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-02 17:33:10 -04:00
Crowdin Bot 464beccd95 New Crowdin translations by GitHub Action 2024-07-02 21:32:31 +00:00
Vitor Pamplona 7519fc2f10 Adds Malware Report type 2024-07-02 17:30:03 -04:00
Vitor Pamplona 01816b0389 Adds a check to make sure the NIP-96 uploader is a url before adding to the list.
Solves NPE on the model if they are not urls
2024-07-02 17:29:16 -04:00
Believethehype cc8e432336 make sure only owner of images get delete dialog in Gallery 2024-07-02 11:42:10 +02:00
Believethehype d1ed73f78b Gallery: get rid of dtag, 2024-07-02 11:36:26 +02:00
Believethehype 7a76bf2ae5 remove unneeded dtag from 10011 2024-07-02 11:31:06 +02:00
KotlinGeekDev 6efb970794 Use keys of map entries, as they are more reliable(from testing). Extract pollViewModel.pollOptions into a separate variable. 2024-07-02 09:51:34 +01:00
believethehypeandGitHub a0a8010305 Merge branch 'main' into gallery 2024-07-02 08:50:44 +02:00
Vitor Pamplona 9ed1dbac72 Updates lifecycle to 2.8.3 2024-07-01 17:13:37 -04:00
Vitor Pamplona c7280cf1be Observes and shows only the user's selected NIP96 servers for uploading options 2024-07-01 17:12:01 -04:00
Believethehype 0ed87db3b3 moved to gallery tag 2024-07-01 22:45:50 +02:00
Vitor Pamplona 20bc814211 Fixes tag. From "relay" to "server" on kind 10096 2024-07-01 16:40:37 -04:00
believethehypeandGitHub 0c92c57b79 Merge branch 'main' into gallery 2024-07-01 22:40:31 +02:00
Vitor Pamplona e61404146b Requests a NIP-96 compliant server 2024-07-01 16:34:44 -04:00
Vitor Pamplona 07dbcbf5c3 Adds FileServer events to be downloaded for the active account. 2024-07-01 16:34:28 -04:00
Believethehype 8421dbfa4c use r tag instead of g (reserved for geotag) 2024-07-01 22:06:40 +02:00
Believethehype d2af7ba407 Merge remote-tracking branch 'upstream/main' into gallery 2024-07-01 22:03:30 +02:00
Vitor PamplonaandGitHub d0718fcda7 Merge pull request #953 from KotlinGeekDev/custom-media-servers
Support for custom media upload servers
2024-07-01 15:30:49 -04:00
believethehypeandGitHub 2372acbb2a Merge branch 'main' into gallery 2024-07-01 20:59:13 +02:00
greenart7c3 9374798f92 Revert "Use Base64Requester"
This reverts commit 2bd05d80b0.
2024-07-01 15:48:22 -03:00
greenart7c3 e96015bcbc Parse base64 inside the wordIdentifier function 2024-07-01 15:07:18 -03:00
greenart7c3 2bd05d80b0 Use Base64Requester 2024-07-01 14:50:19 -03:00
greenart7c3 3a7b503890 Show base64 images 2024-07-01 14:25:29 -03:00
Vitor Pamplona d20707f4df Removes unused params 2024-07-01 12:51:45 -04:00
Vitor PamplonaandGitHub b01d308d65 Merge pull request #966 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-01 12:43:21 -04:00
Crowdin Bot e0f5c730a6 New Crowdin translations by GitHub Action 2024-07-01 15:50:05 +00:00
Vitor Pamplona b9086de336 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-01 11:48:06 -04:00
Vitor Pamplona 405d3d24a0 Removes decryption for DVM responses since it doesn't encrypt statuses 2024-07-01 11:47:58 -04:00
Vitor Pamplona 236de87574 Fixes unable to see original post when clicking on the edit button 2024-07-01 11:47:30 -04:00
Believethehype b12d14d6f5 add a shortpreview to not mess up grid layout on load 2024-07-01 09:19:16 +02:00
Believethehype 1d42440e26 add gallery entry to dictionary 2024-07-01 08:21:00 +02:00
Believethehype ec065a6595 readd regular video elements for regular playing, use isVideo funtion to check 2024-06-30 23:14:37 +02:00
Believethehype 0d83511509 Merge branch 'gallery' of https://github.com/believethehype/amethyst into gallery 2024-06-30 22:29:32 +02:00
Believethehype 9fe08f6c45 allow videos in the gallery 2024-06-30 22:29:11 +02:00
Believethehype 557f2e1a83 Merge remote-tracking branch 'upstream/main' into gallery 2024-06-30 18:02:43 +02:00
Vitor PamplonaandGitHub efced4670b Merge pull request #961 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-30 10:03:49 -04:00
Crowdin Bot c89596809a New Crowdin translations by GitHub Action 2024-06-30 14:00:05 +00:00
Vitor PamplonaandGitHub 7ccc93f17f Merge pull request #959 from greenart7c3/main
add onerror when websocket is null
2024-06-30 09:58:40 -04:00
greenart7c3 f50e511da0 Cut the text when the content length is greater than SHORT_TEXT_LENGTH and theres no spaces and new lines 2024-06-30 10:20:14 -03:00
greenart7c3 3d497ccf4d add onerror when websocket is null 2024-06-30 09:35:17 -03:00
Vitor Pamplona 5c21e219fe v0.88.8 2024-06-29 19:57:53 -04:00
Vitor Pamplona 58796ac31d - Adds created_at as the time of the notification instead of the receiving time
- Displays Zap Push notifications even if the zapped note is not available yet.
2024-06-29 18:51:41 -04:00
Vitor Pamplona 66317f5593 Better nip44 error messages for notifications 2024-06-29 16:20:49 -04:00
KotlinGeekDev 2f276cb155 Remove TODO, since a basic UI is already implemented. 2024-06-29 21:13:17 +01:00
Vitor PamplonaandGitHub b1b915b405 Merge pull request #958 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-29 15:43:01 -04:00
Crowdin Bot 052f9fa21f New Crowdin translations by GitHub Action 2024-06-29 19:38:24 +00:00
believethehypeandGitHub bb00616275 Merge branch 'main' into gallery 2024-06-29 15:31:54 +02:00
Believethehype 581d11702c Merge remote-tracking branch 'upstream/main' into gallery 2024-06-29 15:31:20 +02:00
Believethehype 16eb6097ce actually use the image from the 10011 event 2024-06-29 15:08:58 +02:00
KotlinGeekDev 4238978373 Merge remote-tracking branch 'origin/custom-media-servers' into custom-media-servers 2024-06-29 01:14:20 +01:00
KotlinGeekDev 7f3c7ea69c Remove unnecessary files. 2024-06-29 01:13:16 +01:00
KotlinGeekDevandGitHub a4202c96d2 Merge branch 'main' into custom-media-servers 2024-06-29 00:10:08 +00:00
KotlinGeekDev 593f817b6d Try fix(again). 2024-06-29 00:56:08 +01:00
KotlinGeekDev af3a52c75e Try fixing this again. 2024-06-29 00:22:06 +01:00
KotlinGeekDevandGitHub f9b1cbc9ba Merge branch 'vitorpamplona:main' into custom-media-servers 2024-06-28 23:13:24 +00:00
KotlinGeekDev 9aa511b398 Refactor: location of files. 2024-06-29 00:02:44 +01:00
KotlinGeekDevandGitHub 5257b1e1cc Merge branch 'main' into custom-media-servers 2024-06-28 22:50:59 +00:00
KotlinGeekDev e7fec7caab Merge branch 'main' into custom-media-servers
merge main branch
2024-06-28 23:43:46 +01:00
KotlinGeekDev 9a4d829f73 Remove TODO. Make isModified private. 2024-06-28 23:12:04 +01:00
KotlinGeekDev 2423ad6e87 [Squash] Use string resources. Ugly solution to avoid duplicates. 2024-06-28 23:08:08 +01:00
KotlinGeekDev dc40856bc2 Ugly solution to avoid duplicates. 2024-06-28 23:04:25 +01:00
believethehypeandGitHub 5cbea6999e Merge pull request #2 from believethehype/gallery2
Gallery2
2024-06-28 23:57:32 +02:00
Believethehype 0d18a89177 cleanup 2024-06-28 23:53:40 +02:00
Believethehype b935f60fcb Merge remote-tracking branch 'upstream/main' into gallery2 2024-06-28 23:40:37 +02:00
Believethehype 45df7713ce remove text preview from images 2024-06-28 23:38:41 +02:00
KotlinGeekDev b26355097d Refactor renderMediaServerList to use callbacks. Use addServerList. 2024-06-28 22:26:24 +01:00
KotlinGeekDev feee845d31 Add convenience function for adding default Amethyst list. 2024-06-28 22:19:21 +01:00
KotlinGeekDev 660be862d6 Merge remote-tracking branch 'origin/custom-media-servers' into custom-media-servers 2024-06-28 22:18:05 +01:00
KotlinGeekDev b5047f213e Move media server list rendering to separate function. Fix padding for rendered items. Rename onDelete to onAddOrDelete() for component reuse. 2024-06-28 22:17:42 +01:00
Believethehype f331398316 gallery is visualised 2024-06-28 21:13:52 +02:00
KotlinGeekDev 6c88029fe3 Mve media server list rendering to separate function. Fix padding for rendered items. Rename onDelete to onAddOrDelete() for component reuse. 2024-06-28 20:01:41 +01:00
KotlinGeekDev aae7236106 Introduce MediaServerEdit component. 2024-06-28 19:57:57 +01:00
KotlinGeekDev 963a42becf Make first character in derived name uppercase, for ...aesthetics. 2024-06-28 19:56:05 +01:00
Believethehype a42762de53 alternative attempt.. 2024-06-28 19:14:36 +02:00
Believethehype e073e58241 Update GalleryListEvent.kt 2024-06-28 16:51:09 +02:00
Believethehype 91caacd36d added first attempt to add user galleries. can read 10011 events and shows profile tab
What works:
- can load existing 10011 lists and show images in profile tab

What doesn't work:
- gallery view is broken
- can't load notes to click images to get to original note
- adding media to gallery crashes amethyst atm
- no functionality to delete media from gallery yet.
2024-06-28 16:01:56 +02:00
KotlinGeekDev 0a6d9a7764 Normalize server URL before adding it, Part 2. 2024-06-27 17:17:49 +01:00
KotlinGeekDev 409b63b374 Normalize server URL before adding it. 2024-06-27 16:23:27 +01:00
KotlinGeekDev cd9ba9befe Use divider to separate custom and hardcoded server options. Introduce new padding value. 2024-06-26 16:09:56 +01:00
KotlinGeekDevandGitHub 67ef25f538 Merge branch 'vitorpamplona:main' into custom-media-servers 2024-06-22 18:28:55 +00:00
KotlinGeekDevandGitHub a19deb2076 Merge branch 'vitorpamplona:main' into custom-media-servers 2024-06-21 13:55:16 +00:00
KotlinGeekDev bba7dba17c Introduce MediaServersListView and add it to Drawer. 2024-06-21 14:52:08 +01:00
KotlinGeekDevandGitHub b6fd067d48 Merge branch 'vitorpamplona:main' into custom-media-servers 2024-06-20 12:46:25 +00:00
KotlinGeekDev ed5d7335a0 Simplify addServer() and removeServer(). Introduce saveFileServers() for saving changes, and broadcasting them. 2024-06-13 11:08:44 +01:00
KotlinGeekDev 76e3b5abe7 Add rudimentary implementations for addServer() and removeServer(). 2024-06-13 02:19:18 +01:00
KotlinGeekDev 353a30efe4 Introduce MediaServersViewModel. Basic things for now. 2024-06-11 16:46:49 +01:00
300 changed files with 6423 additions and 2425 deletions
+2 -2
View File
@@ -18,11 +18,11 @@ jobs:
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
distribution: 'zulu'
java-version: 17
- name: Cache gradle
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+2 -2
View File
@@ -15,11 +15,11 @@ jobs:
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
distribution: 'zulu'
java-version: 17
- name: Cache gradle
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+1
View File
@@ -42,6 +42,7 @@ out/
.gradle
.gradle/
build/
.kotlin/
# Local configuration file (sdk path, etc)
local.properties
+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.24" />
<option name="version" value="2.0.0" />
</component>
</project>
+19 -45
View File
@@ -1,20 +1,23 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
}
android {
namespace 'com.vitorpamplona.amethyst'
compileSdk 34
compileSdk libs.versions.android.compileSdk.get().toInteger()
defaultConfig {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 384
versionName "0.88.7"
buildConfigField "String", "RELEASE_NOTES_ID", "\"2a34cbadd03212c8162e1ff896ba12641821088a2ec8d5e40d54aa80c0510800\""
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 390
versionName "0.89.4"
buildConfigField "String", "RELEASE_NOTES_ID", "\"1130badb252bdf62aaf93cb65bfa2bb09e7600c7d6764894f2954555b3b73018\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -132,19 +135,11 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
compose true
buildConfig true
}
composeOptions {
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.14"
}
packagingOptions {
resources {
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
@@ -160,6 +155,16 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation platform(libs.androidx.compose.bom)
@@ -290,34 +295,3 @@ dependencies {
debugImplementation libs.androidx.ui.test.manifest
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
// NB: Android Studio can't find the imports; this does not affect the
// actual build since Gradle can find them just fine.
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
project.afterEvaluate {
tasks.each { task ->
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
task.doLast {
outputs.files.each { file ->
if (file.name.endsWith(".profm")) {
println("Sorting ${file} ...")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}
}
}
@@ -75,9 +75,21 @@ class ImageUploadTesting {
)
val url = result.tags!!.first { it[0] == "url" }.get(1)
val size = result.tags!!.firstOrNull { it[0] == "size" }?.get(1)?.ifBlank { null }
val dim = result.tags!!.firstOrNull { it[0] == "dim" }?.get(1)?.ifBlank { null }
val hash = result.tags!!.firstOrNull { it[0] == "x" }?.get(1)?.ifBlank { null }
val size =
result.tags!!
.firstOrNull { it[0] == "size" }
?.get(1)
?.ifBlank { null }
val dim =
result.tags!!
.firstOrNull { it[0] == "dim" }
?.get(1)
?.ifBlank { null }
val hash =
result.tags!!
.firstOrNull { it[0] == "x" }
?.get(1)
?.ifBlank { null }
val contentType = result.tags!!.first { it[0] == "m" }.get(1)
val ox = result.tags!!.first { it[0] == "ox" }.get(1)
@@ -79,8 +79,7 @@ class ThreadAssemblerTest {
{"id":"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","pubkey":"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194","created_at":1688194800,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8","","reply"]],"content":"another one","sig":"b4bc4d8206a08de0a918043129b27c8863d17f60e4f58b4d2b535326625870d32640b04f247eb9eb5df1d62fe98c8e1b0341bdd119655553488300ec9d5b4036"},
{"id":"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883","pubkey":"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11","created_at":1688234921,"kind":1,"tags":[["a","30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599","","root"],["e","512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e","","reply"]],"content":"yet another one, testing 0.0.3","sig":"13bd41c4029c6a7ee41cb03d12e831e9e9b6e14d43a61c78a72657070b45385ba2ce98e8121049fbcbdb7b2dd777c36c7867ba70b5e6a4798a9b866910ae5b62"}
]
"""
.trimIndent()
""".trimIndent()
val header =
"""
@@ -111,8 +110,7 @@ class ThreadAssemblerTest {
["p","b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc"]
]
}
"""
.trimIndent()
""".trimIndent()
@Test
fun threadOrderTest() =
@@ -55,15 +55,11 @@ object PushDistributorHandler : PushDistributorActions {
endpointInternal = ""
}
override fun getSavedDistributor(): String {
return unifiedPush.getSavedDistributor(appContext) ?: ""
}
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext) ?: ""
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
override fun getInstalledDistributors(): List<String> {
return unifiedPush.getDistributors(appContext)
}
override fun getInstalledDistributors(): List<String> = unifiedPush.getDistributors(appContext)
fun formattedDistributorNames(): List<String> {
val distributorsArray = getInstalledDistributors().toTypedArray()
@@ -87,8 +83,7 @@ object PushDistributorHandler : PushDistributorActions {
it
}
as String
}
.toTypedArray()
}.toTypedArray()
return distributorsNameArray.toList()
}
@@ -126,8 +126,7 @@ class PushMessageReceiver : MessagingReceiver() {
pushHandler.removeEndpoint()
}
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(appContext, NotificationManager::class.java)
fun notificationManager(): NotificationManager =
ContextCompat.getSystemService(appContext, NotificationManager::class.java)
as NotificationManager
}
}
@@ -118,7 +118,5 @@ private class PullRefreshNestedScrollConnection(
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))
}
override suspend fun onPreFling(available: Velocity): Velocity = Velocity(0f, onRelease(available.y))
}
@@ -63,8 +63,7 @@ fun Modifier.pullRefreshIndicatorTransform(
) {
this@drawWithContent.drawContent()
}
}
.graphicsLayer {
}.graphicsLayer {
translationY = state.position - size.height
if (scale && !state.refreshing) {
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
import android.app.Application
import android.content.Context
import android.os.Looper
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
@@ -61,7 +62,8 @@ class Amethyst : Application() {
}
val coilCache: DiskCache by lazy {
DiskCache.Builder()
DiskCache
.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
@@ -75,13 +77,23 @@ class Amethyst : Application() {
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
if (BuildConfig.DEBUG) {
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder().detectAll().penaltyLog().build(),
ThreadPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
VmPolicy.Builder().detectAll().penaltyLog().build(),
VmPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
Looper.getMainLooper().setMessageLogging(LogMonitor())
ChoreographerHelper.start()
}
GlobalScope.launch(Dispatchers.IO) {
@@ -94,13 +106,9 @@ class Amethyst : Application() {
}
}
fun imageLoaderBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(applicationContext).diskCache { coilCache }
}
fun imageLoaderBuilder(): ImageLoader.Builder = ImageLoader.Builder(applicationContext).diskCache { coilCache }
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {
return EncryptedStorage.preferences(instance, npub)
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
companion object {
lateinit var instance: Amethyst
@@ -29,16 +29,15 @@ class EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
}
fun prefsFileName(npub: String? = null): String = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
fun preferences(
applicationContext: Context,
npub: String? = null,
): EncryptedSharedPreferences {
val masterKey: MasterKey =
MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
MasterKey
.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
@@ -0,0 +1,198 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.util.Log
import android.util.Printer
import android.view.Choreographer
import java.text.SimpleDateFormat
import java.util.concurrent.atomic.AtomicBoolean
class LogMonitor : Printer {
private val mStackSampler: StackSampler
private var mPrintingStarted = false
private var mStartTimestamp: Long = 0
// threshold
private val mBlockThresholdMillis: Long = 16
// Sampling frequency
private val mSampleInterval: Long = 1000
private val mLogHandler: Handler
init {
mStackSampler = StackSampler(mSampleInterval)
val handlerThread = HandlerThread("block-canary-io")
handlerThread.start()
mLogHandler = Handler(handlerThread.getLooper())
}
override fun println(x: String) {
// From if to Else, execute DispatchMessage. If the execution takes more than the threshold, the output stuck information
if (!mPrintingStarted) {
// Record start time
mStartTimestamp = System.currentTimeMillis()
mPrintingStarted = true
mStackSampler.startDump()
} else {
val endTime = System.currentTimeMillis()
if (x.indexOf("com.vitorpamplona.amethyst") > 0) {
Log.d("block-canary", "Looper ${endTime - mStartTimestamp}ms for $x")
}
mPrintingStarted = false
//
if (isBlock(endTime)) {
notifyBlockEvent(endTime)
}
mStackSampler.stopDump()
}
}
private fun notifyBlockEvent(endTime: Long) {
mLogHandler.post {
// Obtain the stack of the main thread stack
val stacks: List<String> = mStackSampler.getStacks(mStartTimestamp, endTime)
for (stack in stacks) {
Log.e("block-canary", stack)
}
}
}
private fun isBlock(endTime: Long): Boolean = endTime - mStartTimestamp > mBlockThresholdMillis
}
class StackSampler(
private val mSampleInterval: Long,
) {
private val mHandler: Handler
private val mStackMap: MutableMap<Long, String> = LinkedHashMap()
private val mMaxCount = 100
// Whether to sample
var mShouldSample: AtomicBoolean = AtomicBoolean(false)
/**
* Start sampling and execute stack
*/
fun startDump() {
// Avoid repeating start
if (mShouldSample.get()) {
return
}
mShouldSample.set(true)
mHandler.removeCallbacks(mRunnable)
mHandler.postDelayed(mRunnable, mSampleInterval)
}
fun stopDump() {
if (!mShouldSample.get()) {
return
}
mShouldSample.set(false)
mHandler.removeCallbacks(mRunnable)
}
fun getStacks(
startTime: Long,
endTime: Long,
): List<String> {
val result = mutableListOf<String>()
synchronized(mStackMap) {
for (entryTime in mStackMap.keys) {
if (startTime < entryTime && entryTime < endTime) {
result.add(
TIME_FORMATTER.format(entryTime) +
SEPARATOR +
SEPARATOR +
mStackMap[entryTime],
)
}
}
}
return result
}
init {
val handlerThread = HandlerThread("block-canary-sampler")
handlerThread.start()
mHandler = Handler(handlerThread.looper)
}
private val mRunnable: Runnable =
object : Runnable {
override fun run() {
val sb = StringBuilder()
val stackTrace = Looper.getMainLooper().thread.stackTrace
for (s in stackTrace) {
sb.append(s.toString()).append("\n")
}
synchronized(mStackMap) {
// Save up to 100 stack information
if (mStackMap.size == mMaxCount) {
mStackMap.remove(mStackMap.keys.iterator().next())
}
mStackMap.put(System.currentTimeMillis(), sb.toString())
}
if (mShouldSample.get()) {
mHandler.postDelayed(this, mSampleInterval)
}
}
}
companion object {
const val SEPARATOR: String = "\r\n"
val TIME_FORMATTER: SimpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
}
}
object ChoreographerHelper {
var lastFrameTimeNanos: Long = 0
fun start() {
Choreographer.getInstance().postFrameCallback(
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// Last callback time
if (lastFrameTimeNanos == 0L) {
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
return
}
val diff = (frameTimeNanos - lastFrameTimeNanos) / 1000000
// only report after 30ms because videos play at 30fps
if (diff > 35) {
// Follow the frame number
val droppedCount = (diff / 16.6).toInt()
Log.w("block-canary", "Dropped $droppedCount frames. Skipped $diff ms")
}
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
}
},
)
}
}
@@ -91,6 +91,7 @@ import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.ReportEvent
@@ -107,6 +108,7 @@ import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -116,9 +118,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@@ -473,17 +476,66 @@ class Account(
}
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy {
val liveHomeFollowListFlow: Flow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveHomeList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy {
liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
}
fun relaysFromPeopleListFlows(
currentFollowList: LiveFollowLists,
relayUrlsToIgnore: Set<String>,
): Flow<List<MinimumRelayListProcessor.RelayRecommendation>> =
combine(
currentFollowList.users.map {
getNIP65RelayListFlow(it)
},
) { followsNIP65RelayLists ->
MinimumRelayListProcessor
.reliableRelaySetFor(
followsNIP65RelayLists.mapNotNull {
(it.note.event as? AdvertisedRelayListEvent)
},
relayUrlsToIgnore,
hasOnionConnection = proxy != null,
).sortedByDescending { it.users.size }
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveHomeFollowRelayFlow: Flow<List<MinimumRelayListProcessor.RelayRecommendation>> by lazy {
combineTransform(liveHomeFollowListFlow, connectToRelaysFlow) { followList, existing ->
if (followList != null) {
emit(
relaysFromPeopleListFlows(
followList,
existing.mapNotNullTo(HashSet()) {
if (it.read && FeedType.FOLLOWS in it.feedTypes) {
it.url
} else {
null
}
},
),
)
} else {
emit(MutableStateFlow(emptyList()))
}
}.flatMapLatest {
it
}
}
val liveHomeFollowRelays: StateFlow<List<MinimumRelayListProcessor.RelayRecommendation>> by lazy {
liveHomeFollowRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyList())
}
@OptIn(ExperimentalCoroutinesApi::class)
private val liveNotificationList: Flow<ListNameNotePair> by lazy {
defaultNotificationFollowList
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
defaultNotificationFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveNotificationFollowLists: StateFlow<LiveFollowLists?> by lazy {
@@ -493,10 +545,9 @@ class Account(
@OptIn(ExperimentalCoroutinesApi::class)
private val liveStoriesList: Flow<ListNameNotePair> by lazy {
defaultStoriesFollowList
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
defaultStoriesFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveStoriesFollowLists: StateFlow<LiveFollowLists?> by lazy {
@@ -506,10 +557,9 @@ class Account(
@OptIn(ExperimentalCoroutinesApi::class)
private val liveDiscoveryList: Flow<ListNameNotePair> by lazy {
defaultDiscoveryFollowList
.transformLatest { listName ->
emit(loadPeopleListFlowFromListName(listName))
}.flattenMerge()
defaultDiscoveryFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveDiscoveryFollowLists: StateFlow<LiveFollowLists?> by lazy {
@@ -2197,6 +2247,36 @@ class Account(
}
}
fun addToGallery(
idHex: String,
url: String,
relay: String?,
) {
if (!isWriteable()) return
ProfileGalleryEntryEvent.create(
url = url,
eventid = idHex,
relayhint = relay,
/*magnetUri = magnetUri,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent, */
signer = signer,
) { event ->
Client.send(event)
LocalCache.consume(event, null)
}
}
fun removeFromGallery(note: Note) {
delete(note)
}
fun addBookmark(
note: Note,
isPrivate: Boolean,
@@ -2681,19 +2761,22 @@ class Account(
// Takes a User's relay list and adds the types of feeds they are active for.
fun activeRelays(): Array<RelaySetupInfo>? {
val usersRelayList =
userProfile().latestContactList?.relays()?.map {
val url = RelayUrlFormatter.normalize(it.key)
userProfile()
.latestContactList
?.relays()
?.map {
val url = RelayUrlFormatter.normalize(it.key)
val localFeedTypes =
localRelays.firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url }?.feedTypes?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT))
?: Constants.defaultRelays
.filter { defaultRelay -> defaultRelay.url == url }
.firstOrNull()
?.feedTypes
?: Constants.activeTypesGlobalChats
val localFeedTypes =
localRelays.firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url }?.feedTypes?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT))
?: Constants.defaultRelays
.filter { defaultRelay -> defaultRelay.url == url }
.firstOrNull()
?.feedTypes
?: Constants.activeTypesGlobalChats
RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes)
} ?: return null
RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes)
}?.ifEmpty { null } ?: return null
return usersRelayList.toTypedArray()
}
@@ -2896,14 +2979,14 @@ class Account(
}
}
fun getNIP65RelayListNote(): AddressableNote =
fun getNIP65RelayListNote(pubkey: HexKey = signer.pubKey): AddressableNote =
LocalCache.getOrCreateAddressableNote(
AdvertisedRelayListEvent.createAddressATag(signer.pubKey),
AdvertisedRelayListEvent.createAddressATag(pubkey),
)
fun getNIP65RelayListFlow(): StateFlow<NoteState> = getNIP65RelayListNote().flow().metadata.stateFlow
fun getNIP65RelayListFlow(pubkey: HexKey = signer.pubKey): StateFlow<NoteState> = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow
fun getNIP65RelayList(): AdvertisedRelayListEvent? = getNIP65RelayListNote().event as? AdvertisedRelayListEvent
fun getNIP65RelayList(pubkey: HexKey = signer.pubKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent
fun sendNip65RelayList(relays: List<AdvertisedRelayListEvent.AdvertisedRelayInfo>) {
if (!isWriteable()) return
@@ -33,7 +33,10 @@ import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.Dispatchers
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
data class Spammer(
val pubkeyHex: HexKey,
var duplicatedMessages: Set<HexKey>,
)
class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
@@ -109,7 +112,9 @@ class AntiSpamFilter {
}
@Stable
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
class AntiSpamLiveData(
val cache: AntiSpamFilter,
) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
@@ -126,4 +131,6 @@ class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(Anti
}
}
class AntiSpamState(val cache: AntiSpamFilter)
class AntiSpamState(
val cache: AntiSpamFilter,
)
@@ -37,7 +37,9 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
@Stable
class PublicChatChannel(idHex: String) : Channel(idHex) {
class PublicChatChannel(
idHex: String,
) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(
@@ -49,26 +51,22 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info.name ?: super.toBestDisplayName()
}
override fun toBestDisplayName(): String = info.name ?: super.toBestDisplayName()
override fun summary(): String? {
return info.about
}
override fun summary(): String? = info.about
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return super.profilePicture()
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
}
override fun anyNameStartsWith(prefix: String): Boolean = listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
}
@Stable
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
class LiveActivitiesChannel(
val address: ATag,
) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr()
@@ -86,27 +84,22 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info?.title() ?: super.toBestDisplayName()
}
override fun toBestDisplayName(): String = info?.title() ?: super.toBestDisplayName()
override fun summary(): String? {
return info?.summary()
}
override fun summary(): String? = info?.summary()
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null }
}
override fun profilePicture(): String? = info?.image()?.ifBlank { null }
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary())
override fun anyNameStartsWith(prefix: String): Boolean =
listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }
.isNotEmpty()
}
}
@Stable
abstract class Channel(val idHex: String) {
abstract class Channel(
val idHex: String,
) {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = LargeCache<HexKey, Note>()
@@ -118,21 +111,13 @@ abstract class Channel(val idHex: String) {
open fun idDisplayNote() = idNote().toShortenHex()
open fun toBestDisplayName(): String {
return idDisplayNote()
}
open fun toBestDisplayName(): String = idDisplayNote()
open fun summary(): String? {
return null
}
open fun summary(): String? = null
open fun creatorName(): String? {
return creator?.toBestDisplayName()
}
open fun creatorName(): String? = creator?.toBestDisplayName()
open fun profilePicture(): String? {
return creator?.info?.banner
}
open fun profilePicture(): String? = creator?.info?.banner
open fun updateChannelInfo(
creator: User,
@@ -167,10 +152,10 @@ abstract class Channel(val idHex: String) {
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important =
notes.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == false
}
.sortedWith(DefaultFeedOrder)
notes
.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == false
}.sortedWith(DefaultFeedOrder)
.take(500)
.toSet()
@@ -182,7 +167,9 @@ abstract class Channel(val idHex: String) {
}
}
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
class ChannelLiveData(
val channel: Channel,
) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
@@ -208,4 +195,6 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
}
}
class ChannelState(val channel: Channel)
class ChannelState(
val channel: Channel,
)
@@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class Chatroom() {
class Chatroom {
var authors: Set<User> = setOf()
var roomMessages: Set<Note> = setOf()
var subject: String? = null
@@ -72,9 +72,7 @@ class Chatroom() {
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return authors.any { it.pubkeyHex in keySet }
}
fun senderIntersects(keySet: Set<HexKey>): Boolean = authors.any { it.pubkeyHex in keySet }
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
@@ -68,8 +68,8 @@ fun RenderHashTagIcons() {
}
}
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
when (tag.lowercase()) {
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
"lightning", "lightningnetwork" -> lightning
@@ -86,7 +86,6 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
else -> null
}
}
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
@@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RecommendRelayEvent
import com.vitorpamplona.quartz.events.RelaySetEvent
@@ -148,8 +149,8 @@ object LocalCache {
val deletionIndex = DeletionIndex()
private val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
private val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
fun <T : Event> observeETag(
kind: Int,
@@ -210,9 +211,10 @@ object LocalCache {
}
private fun updateObservables(event: Event) {
val observablesOfKind = observablesByKindAndETag[event.kind] ?: return
event.forEachTaggedEvent {
observablesOfKind[it]?.updateIfMatches(event)
observablesByKindAndETag[event.kind]?.let { observablesOfKind ->
event.forEachTaggedEvent {
observablesOfKind[it]?.updateIfMatches(event)
}
}
observablesByKindAndAuthor[event.kind]?.get(event.pubKey)?.updateIfMatches(event)
@@ -1216,6 +1218,19 @@ object LocalCache {
.deleteEvents()
.mapNotNull { getNoteIfExists(it) }
.forEach { deleteNote ->
val deleteNoteEvent = deleteNote.event
if (deleteNoteEvent is AddressableEvent) {
val addressableNote = getAddressableNoteIfExists(deleteNoteEvent.addressTag())
if (addressableNote?.author?.pubkeyHex == event.pubKey && (addressableNote.createdAt() ?: 0) <= event.createdAt) {
// Counts the replies
deleteNote(addressableNote)
addressables.remove(addressableNote.idHex)
deletedAtLeastOne = true
}
}
// must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey) {
// reverts the add
@@ -1668,6 +1683,26 @@ object LocalCache {
refreshObservers(note)
}
fun consume(
event: ProfileGalleryEntryEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: FileStorageHeaderEvent,
relay: Relay?,
@@ -2336,7 +2371,7 @@ object LocalCache {
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData()
val live: LocalCacheFlow = LocalCacheFlow()
private fun refreshObservers(newNote: Note) {
updateObservables(newNote.event as Event)
@@ -2484,7 +2519,28 @@ object LocalCache {
event: Event,
relay: Relay?,
) {
if (deletionIndex.hasBeenDeleted(event)) return
if (deletionIndex.hasBeenDeleted(event)) {
// update relay with deletion event from another.
if (relay != null) {
deletionIndex.hasBeenDeletedBy(event)?.let {
Log.d("LocalCache", "Updating ${relay.url} with a Deletion Event ${it.toJson()} because of ${event.toJson()}")
relay.send(it)
}
}
return
}
if (event is AddressableEvent && relay != null) {
// updates relay with a new event.
getAddressableNoteIfExists(event.addressTag())?.let {
it.event?.let { existingEvent ->
if (existingEvent.createdAt() > event.createdAt) {
Log.d("LocalCache", "Updating ${relay.url} with a new version of ${event.toJson()} to ${existingEvent.toJson()}")
relay.send(existingEvent)
}
}
}
}
checkNotInMainThread()
@@ -2529,6 +2585,7 @@ object LocalCache {
}
is FhirResourceEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is ProfileGalleryEntryEvent -> consume(event, relay)
is FileServersEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
@@ -2603,7 +2660,7 @@ object LocalCache {
}
@Stable
class LocalCacheLiveData {
class LocalCacheFlow {
private val _newEventBundles = MutableSharedFlow<Set<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
@@ -66,9 +66,7 @@ class HumanName(
var family: String? = null,
var given: ArrayList<String> = arrayListOf(),
) {
fun assembleName(): String {
return given.joinToString(" ") + " " + family
}
fun assembleName(): String = given.joinToString(" ") + " " + family
}
class Bundle(
@@ -139,6 +139,8 @@ open class Note(
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var associatedNote: Note? = null
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)
@@ -62,8 +62,7 @@ class ThreadAssembler {
} else {
null
}
}
?.filterNotNull()
}?.filterNotNull()
if (roots != null && roots.isNotEmpty()) {
return roots[0]
@@ -148,9 +147,7 @@ class OnlyLatestVersionSet : MutableSet<Note> {
}
}
override fun addAll(elements: Collection<Note>): Boolean {
return elements.map { add(it) }.any()
}
override fun addAll(elements: Collection<Note>): Boolean = elements.map { add(it) }.any()
override val size: Int
get() = set.size
@@ -160,29 +157,17 @@ class OnlyLatestVersionSet : MutableSet<Note> {
map.clear()
}
override fun isEmpty(): Boolean {
return set.isEmpty()
}
override fun isEmpty(): Boolean = set.isEmpty()
override fun containsAll(elements: Collection<Note>): Boolean {
return set.containsAll(elements)
}
override fun containsAll(elements: Collection<Note>): Boolean = set.containsAll(elements)
override fun contains(element: Note): Boolean {
return set.contains(element)
}
override fun contains(element: Note): Boolean = set.contains(element)
override fun iterator(): MutableIterator<Note> {
return set.iterator()
}
override fun iterator(): MutableIterator<Note> = set.iterator()
override fun retainAll(elements: Collection<Note>): Boolean {
return set.retainAll(elements)
}
override fun retainAll(elements: Collection<Note>): Boolean = set.retainAll(elements)
override fun removeAll(elements: Collection<Note>): Boolean {
return elements.map { remove(it) }.any()
}
override fun removeAll(elements: Collection<Note>): Boolean = elements.map { remove(it) }.any()
override fun remove(element: Note): Boolean {
element.address()?.let {
@@ -27,16 +27,20 @@ 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?)
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)
private fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
}
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
@@ -76,8 +80,7 @@ object ThreadLevelCalculator {
accountFollowingSet,
now,
).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
}.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
@@ -74,7 +74,6 @@ object UrlCachedPreviewer {
onReady(state)
}
},
)
.fetchUrlPreview()
).fetchUrlPreview()
}
}
@@ -488,6 +488,7 @@ class UserLiveSet(
val innerRelayInfo = UserBundledRefresherLiveData(u)
val innerZaps = UserBundledRefresherLiveData(u)
val innerBookmarks = UserBundledRefresherLiveData(u)
val innerGallery = UserBundledRefresherLiveData(u)
val innerStatuses = UserBundledRefresherLiveData(u)
// UI Observers line up here.
@@ -500,6 +501,7 @@ class UserLiveSet(
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val gallery = innerGallery.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
@@ -518,6 +520,7 @@ class UserLiveSet(
relayInfo.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
gallery.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
@@ -533,6 +536,7 @@ class UserLiveSet(
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerGallery.destroy()
innerStatuses.destroy()
}
}
@@ -27,7 +27,10 @@ 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) {
class LatestByKindAndAuthor<T : Event>(
private val kind: Int,
private val pubkey: String,
) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
@@ -41,32 +44,38 @@ class LatestByKindAndAuthor<T : Event>(private val kind: Int, private val pubkey
}
}
fun canDelete(): Boolean {
return _latest.subscriptionCount.value == 0
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
fun restart() {
val latestNote =
if ((kind in 10000..19999) || (kind in 30000..39999)) {
LocalCache.addressables
.maxOrNullOf(
filter = { idHex: String, note: AddressableNote ->
note.event?.let {
it.kind() == kind && it.pubKey() == pubkey
} == true
},
comparator = CreatedAtComparatorAddresses,
)?.event as? T
} else {
LocalCache.notes
.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind() == kind && it.pubKey() == pubkey
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
}
if (_latest.value != latestNote) {
_latest.tryEmit(latestNote)
}
}
suspend fun init() {
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)
restart()
}
}
@@ -26,7 +26,10 @@ 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) {
class LatestByKindWithETag<T : Event>(
private val kind: Int,
private val eTag: String,
) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
@@ -40,21 +43,26 @@ class LatestByKindWithETag<T : Event>(private val kind: Int, private val eTag: S
}
}
fun canDelete(): Boolean {
return _latest.subscriptionCount.value == 0
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
fun restart() {
val latestNote =
LocalCache.notes
.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind() == kind && it.isTaggedEvent(eTag)
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
if (_latest.value != latestNote) {
_latest.tryEmit(latestNote)
}
}
suspend fun init() {
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)
restart()
}
}
@@ -26,7 +26,9 @@ import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.Event
abstract class AmethystNostrDataSource(debugName: String) : NostrDataSource(debugName) {
abstract class AmethystNostrDataSource(
debugName: String,
) : NostrDataSource(debugName) {
override fun consume(
event: Event,
relay: Relay,
@@ -59,9 +59,7 @@ class BlurHashFetcher(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher {
return BlurHashFetcher(options, data)
}
): Fetcher = BlurHashFetcher(options, data)
}
}
@@ -72,7 +70,8 @@ object BlurHashRequester {
): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest.Builder(context)
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.build()
@@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.quartz.events.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean {
return Character.charCount(this.codePointAt(pos)) == 2
}
fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2
fun String.firstFullCharOld(): String {
return when (this.length) {
@@ -236,10 +236,10 @@ fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
}
}
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
override fun getSize(): Long {
return imageData.size.toLong()
}
class ByteArrayMediaDataSource(
var imageData: ByteArray,
) : MediaDataSource() {
override fun getSize(): Long = imageData.size.toLong()
@Throws(IOException::class)
override fun readAt(
@@ -33,7 +33,9 @@ import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(context: Context) {
class LocationUtil(
context: Context,
) {
companion object {
const val MIN_TIME: Long = 1000L
const val MIN_DISTANCE: Float = 0.0f
@@ -96,9 +98,7 @@ class LocationUtil(context: Context) {
object CachedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? {
return locationNames[geoHashStr]
}
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
suspend fun geoLocate(
geoHashStr: String,
@@ -31,4 +31,6 @@ fun checkNotInMainThread() {
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
class OnMainThreadException(str: String) : RuntimeException(str)
class OnMainThreadException(
str: String,
) : RuntimeException(str)
@@ -38,7 +38,10 @@ object Nip96MediaServers {
ServerName("Void.cat", "https://void.cat"),
)
data class ServerName(val name: String, val baseUrl: String)
data class ServerName(
val name: String,
val baseUrl: String,
)
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
@@ -83,7 +86,8 @@ class Nip96Retriever {
checkNotInMainThread()
val request: Request =
Request.Builder()
Request
.Builder()
.header("Accept", "application/nostr+json")
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
@@ -110,6 +110,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
),
authors = listOf(account.userProfile().pubkeyHex),
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
@@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
@@ -108,6 +110,33 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
)
}
fun createFollowMetadataAndReleaseFilter(): TypedFilter? {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.shuffled()?.ifEmpty { null }
return if (followSet != null) {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
),
authors = followSet.take(500),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
)
} else {
null
}
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
@@ -235,6 +264,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
followAccountChannel.typedFilters =
listOfNotNull(
createFollowAccountsFilter(),
createFollowMetadataAndReleaseFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
@@ -44,21 +44,24 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
private var addressesToWatch = setOf<Note>()
private var nextEventsToWatch = setOf<Note>()
private var nextAddressesToWatch = setOf<Note>()
private var eventsToWatchInProd = setOf<Note>()
private var addressesToWatchInProd = setOf<Note>()
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val addressesToWatch =
val myAddressesToWatch =
(
eventsToWatch.filter { it.address() != null } +
addressesToWatch.filter { it.address() != null }
eventsToWatchInProd.filter { it.address() != null } +
addressesToWatchInProd.filter { it.address() != null }
).toSet()
if (addressesToWatch.isEmpty()) {
if (myAddressesToWatch.isEmpty()) {
return null
}
return groupByEOSEPresence(addressesToWatch)
return groupByEOSEPresence(myAddressesToWatch)
.map {
listOf(
TypedFilter(
@@ -102,13 +105,13 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}
private fun createAddressFilter(): List<TypedFilter>? {
val addressesToWatch = addressesToWatch.filter { it.event == null }
val myAddressesToWatch = addressesToWatchInProd.filter { it.event == null }
if (addressesToWatch.isEmpty()) {
if (myAddressesToWatch.isEmpty()) {
return null
}
return addressesToWatch.mapNotNull {
return myAddressesToWatch.mapNotNull {
it.address()?.let { aTag ->
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
@@ -137,11 +140,11 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
if (eventsToWatch.isEmpty()) {
if (eventsToWatchInProd.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatch)
return groupByEOSEPresence(eventsToWatchInProd)
.map {
listOf(
TypedFilter(
@@ -187,11 +190,11 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}
private fun createQuotesFilter(): List<TypedFilter>? {
if (eventsToWatch.isEmpty()) {
if (eventsToWatchInProd.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatch)
return groupByEOSEPresence(eventsToWatchInProd)
.map {
listOf(
TypedFilter(
@@ -210,10 +213,10 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatch.filter { it.event == null }
val directEventsToLoad = eventsToWatchInProd.filter { it.event == null }
val threadingEventsToLoad =
eventsToWatch
eventsToWatchInProd
.mapNotNull { it.replyTo }
.flatten()
.filter { it !is AddressableNote && it.event == null }
@@ -243,7 +246,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
checkNotInMainThread()
eventsToWatch.forEach {
eventsToWatchInProd.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
@@ -252,7 +255,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}
}
addressesToWatch.forEach {
addressesToWatchInProd.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
@@ -267,6 +270,9 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}
override fun updateChannelFilters() {
addressesToWatchInProd = nextAddressesToWatch
eventsToWatchInProd = nextEventsToWatch
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
@@ -278,29 +284,29 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
}
fun add(eventId: Note) {
if (!eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.plus(eventId)
if (!nextEventsToWatch.contains(eventId)) {
nextEventsToWatch = nextEventsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: Note) {
if (eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.minus(eventId)
if (nextEventsToWatch.contains(eventId)) {
nextEventsToWatch = nextEventsToWatch.minus(eventId)
invalidateFilters()
}
}
fun addAddress(addressableNote: Note) {
if (!addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.plus(addressableNote)
if (!nextAddressesToWatch.contains(addressableNote)) {
nextAddressesToWatch = nextAddressesToWatch.plus(addressableNote)
invalidateFilters()
}
}
fun removeAddress(addressableNote: Note) {
if (addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.minus(addressableNote)
if (nextAddressesToWatch.contains(addressableNote)) {
nextAddressesToWatch = nextAddressesToWatch.minus(addressableNote)
invalidateFilters()
}
}
@@ -325,8 +331,8 @@ fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
notes.forEach {
it.lastReactionsDownloadTime.forEach {
notes.forEach { note ->
note.lastReactionsDownloadTime.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
@@ -153,6 +154,20 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
)
}
fun createProfileGalleryFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
kinds =
listOf(ProfileGalleryEntryEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1000,
),
)
}
fun createReceivedAwardsFilter() =
user?.let {
TypedFilter(
@@ -173,6 +188,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createProfileGalleryFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter(),
@@ -32,7 +32,10 @@ import okhttp3.Request
import okio.ByteString.Companion.toByteString
import kotlin.coroutines.cancellation.CancellationException
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
@Immutable data class OnlineCheckResult(
val timeInMs: Long,
val online: Boolean,
)
object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
@@ -58,7 +61,8 @@ object OnlineChecker {
val result =
if (url.startsWith("wss")) {
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.replace("wss+livekit://", "wss://"))
.header("Upgrade", "websocket")
@@ -69,7 +73,9 @@ object OnlineChecker {
.build()
val client =
HttpClientManager.getHttpClient().newBuilder()
HttpClientManager
.getHttpClient()
.newBuilder()
.eventListener(EventListener.NONE)
.protocols(listOf(Protocol.HTTP_1_1))
.build()
@@ -80,7 +86,8 @@ object OnlineChecker {
}
} else {
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
@@ -28,15 +28,12 @@ object PackageUtils {
private fun isPackageInstalled(
context: Context,
target: String,
): Boolean {
return context.packageManager.getInstalledApplications(0).find { info ->
): Boolean =
context.packageManager.getInstalledApplications(0).find { info ->
info.packageName == target
} != null
}
fun isOrbotInstalled(context: Context): Boolean {
return isPackageInstalled(context, "org.torproject.android")
}
fun isOrbotInstalled(context: Context): Boolean = isPackageInstalled(context, "org.torproject.android")
fun isExternalSignerInstalled(context: Context): Boolean {
val intent =
@@ -27,14 +27,15 @@ import kotlinx.coroutines.CancellationException
import java.text.NumberFormat
@Stable
data class InvoiceAmount(val invoice: String, val amount: String?)
data class InvoiceAmount(
val invoice: String,
val amount: String?,
)
object CachedLnInvoiceParser {
val lnInvoicesCache = LruCache<String, InvoiceAmount>(20)
fun cached(lnurl: String): InvoiceAmount? {
return lnInvoicesCache[lnurl]
}
fun cached(lnurl: String): InvoiceAmount? = lnInvoicesCache[lnurl]
fun parse(lnbcWord: String): InvoiceAmount? {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
@@ -55,11 +55,14 @@ class EventNotificationConsumer(
// PushNotification Wraps don't include a receiver.
// Test with all logged in accounts
var matchAccount = false
LocalPreferences.allSavedAccounts().forEach {
if (it.hasPrivKey || it.loggedInWithExternalSigner) {
if (!matchAccount && it.hasPrivKey || it.loggedInWithExternalSigner) {
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
Log.d("EventNotificationConsumer", "New Notification Testing if for ${it.npub}")
try {
consumeIfMatchesAccount(event, acc)
matchAccount = true
} catch (e: Exception) {
// Not for this account.
}
@@ -158,6 +161,7 @@ class EventNotificationConsumer(
event.id,
content,
user,
event.createdAt,
userPicture,
noteUri,
applicationContext,
@@ -198,7 +202,7 @@ class EventNotificationConsumer(
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager()
.sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
.sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext)
}
}
}
@@ -241,31 +245,63 @@ class EventNotificationConsumer(
val author = LocalCache.getOrCreateUser(it.pubKey)
val senderInfo = Pair(author, it.content.ifBlank { null })
acc.decryptContent(noteZapped) {
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note")
if (noteZapped.event?.content() != null) {
acc.decryptContent(noteZapped) {
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note")
val zappedContent = it.split("\n").get(0)
val zappedContent = it.split("\n").get(0)
val user = senderInfo.first.toBestDisplayName()
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
var content =
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_from,
user,
)
zappedContent.let {
content +=
" " +
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
notificationManager()
.sendZapNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
} else {
// doesn't have a base note to refer to.
Log.d("EventNotificationConsumer", "Notify Zapped note not available")
val user = senderInfo.first.toBestDisplayName()
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
var content =
val content =
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_from,
user,
)
zappedContent.let {
content +=
" " +
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
}
val userPicture = senderInfo?.first?.profilePicture()
val userPicture = senderInfo.first.profilePicture()
val noteUri = "nostr:Notifications"
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
@@ -275,6 +311,7 @@ class EventNotificationConsumer(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
@@ -90,6 +90,7 @@ object NotificationUtils {
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
@@ -101,6 +102,7 @@ object NotificationUtils {
id,
messageBody,
messageTitle,
time,
pictureUrl,
uri,
channelId,
@@ -113,6 +115,7 @@ object NotificationUtils {
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
@@ -124,6 +127,7 @@ object NotificationUtils {
id,
messageBody,
messageTitle,
time,
pictureUrl,
uri,
channelId,
@@ -136,6 +140,7 @@ object NotificationUtils {
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
channelId: String,
@@ -151,6 +156,7 @@ object NotificationUtils {
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = imageResult.drawable as? BitmapDrawable,
uri = uri,
channelId,
@@ -162,6 +168,7 @@ object NotificationUtils {
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = null,
uri = uri,
channelId,
@@ -175,6 +182,7 @@ object NotificationUtils {
id: String,
messageBody: String,
messageTitle: String,
time: Long,
picture: BitmapDrawable?,
uri: String,
channelId: String,
@@ -218,6 +226,7 @@ object NotificationUtils {
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setWhen(time * 1000)
// Build the notification
val builder =
@@ -236,6 +245,7 @@ object NotificationUtils {
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setWhen(time * 1000)
notify(notId, builder.build())
}
@@ -42,7 +42,8 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
val url = "$BLOCKSTREAM_API_URL/block/$hash"
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/json")
.url(url)
@@ -79,7 +80,8 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
val url = "$BLOCKSTREAM_API_URL/block-height/$height"
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
@@ -33,7 +33,10 @@ import java.util.concurrent.BlockingQueue
/**
* For making async calls to a calendar server
*/
class OkHttpCalendarAsyncSubmit(private val url: String, private val digest: ByteArray) : ICalendarAsyncSubmit {
class OkHttpCalendarAsyncSubmit(
private val url: String,
private val digest: ByteArray,
) : ICalendarAsyncSubmit {
private var queue: BlockingQueue<Optional<Timestamp>>? = null
fun setQueue(queue: BlockingQueue<Optional<Timestamp>>?) {
@@ -49,7 +52,8 @@ class OkHttpCalendarAsyncSubmit(private val url: String, private val digest: Byt
val requestBody = digest.toRequestBody(mediaType)
val request =
okhttp3.Request.Builder()
okhttp3.Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
@@ -25,14 +25,10 @@ import com.vitorpamplona.quartz.ots.ICalendar
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
class OkHttpCalendarBuilder : CalendarBuilder {
override fun newSyncCalendar(url: String): ICalendar {
return OkHttpCalendar(url)
}
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url)
override fun newAsyncCalendar(
url: String,
digest: ByteArray,
): ICalendarAsyncSubmit {
return OkHttpCalendarAsyncSubmit(url, digest)
}
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest)
}
@@ -59,12 +59,12 @@ class VideoCache {
// This method should be called when proxy setting changes.
fun renewCacheFactory(client: OkHttpClient) {
cacheDataSourceFactory =
CacheDataSource.Factory()
CacheDataSource
.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(client),
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
).setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
fun get(client: OkHttpClient): CacheDataSource.Factory {
@@ -32,7 +32,5 @@ class VideoViewedPositionCache {
cachedPosition.put(uri, position)
}
fun get(uri: String): Long? {
return cachedPosition.get(uri)
}
fun get(uri: String): Long? = cachedPosition.get(uri)
}
@@ -44,11 +44,7 @@ class WssDataStreamCollector : WebSocketListener() {
wssData.removeAll(wssData)
}
fun canStream(): Boolean {
return wssData.size > 0
}
fun canStream(): Boolean = wssData.size > 0
fun getNextStream(): ByteString {
return wssData.pollFirst()
}
fun getNextStream(): ByteString = wssData.pollFirst()
}
@@ -32,7 +32,9 @@ import okhttp3.WebSocket
import kotlin.math.min
@OptIn(UnstableApi::class)
class WssStreamDataSource(val httpClient: OkHttpClient) : BaseDataSource(true) {
class WssStreamDataSource(
val httpClient: OkHttpClient,
) : BaseDataSource(true) {
val dataStreamCollector: WssDataStreamCollector = WssDataStreamCollector()
var webSocketClient: WebSocket? = null
@@ -46,11 +48,14 @@ class WssStreamDataSource(val httpClient: OkHttpClient) : BaseDataSource(true) {
// 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(),
Request
.Builder()
.apply {
dataSpec.httpRequestHeaders.forEach { entry ->
addHeader(entry.key, entry.value)
}
}.url(dataSpec.uri.toString())
.build(),
dataStreamCollector,
)
@@ -106,7 +111,9 @@ class WssStreamDataSource(val httpClient: OkHttpClient) : BaseDataSource(true) {
}
// Factory class for DataSource
class Factory(val okHttpClient: OkHttpClient) : DataSource.Factory {
class Factory(
val okHttpClient: OkHttpClient,
) : DataSource.Factory {
override fun createDataSource(): DataSource = WssStreamDataSource(okHttpClient)
}
}
@@ -22,7 +22,10 @@ package com.vitorpamplona.amethyst.service.previews
import kotlinx.coroutines.CancellationException
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
class BahaUrlPreview(
val url: String,
var callback: IUrlPreviewCallback?,
) {
suspend fun fetchUrlPreview(timeOut: Int = 30000) =
try {
fetch(timeOut)
@@ -40,11 +40,7 @@ class UrlInfoItem(
image
}
fun fetchComplete(): Boolean {
return url.isNotEmpty() && image.isNotEmpty()
}
fun fetchComplete(): Boolean = url.isNotEmpty() && image.isNotEmpty()
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
fun allFetchComplete(): Boolean = title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
@@ -71,7 +71,12 @@ suspend fun getDocument(
timeOut: Int = 30000,
): UrlInfoItem =
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).get().build()
val request: Request =
Request
.Builder()
.url(url)
.get()
.build()
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
if (it.isSuccessful) {
@@ -132,8 +137,8 @@ private val UNICODE_BOMS =
"ffff0000".decodeHex(),
)
private fun BufferedSource.readBomAsCharset(): Charset? {
return when (select(UNICODE_BOMS)) {
private fun BufferedSource.readBomAsCharset(): Charset? =
when (select(UNICODE_BOMS)) {
0 -> Charsets.UTF_8
1 -> Charsets.UTF_16BE
2 -> Charsets.UTF_16LE
@@ -142,7 +147,6 @@ private fun BufferedSource.readBomAsCharset(): Charset? {
-1 -> null
else -> throw AssertionError()
}
}
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
@@ -158,7 +162,8 @@ private fun detectCharset(bodyBytes: ByteArray): Charset {
}
}
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
RE_CONTENT_TYPE_CHARSET.find(meta.attr(CONTENT))
RE_CONTENT_TYPE_CHARSET
.find(meta.attr(CONTENT))
?.let {
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
}?.let {
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
class EOSERelayList(
var relayList: Map<String, EOSETime> = emptyMap(),
) {
fun addOrUpdate(
relayUrl: String,
time: Long,
@@ -37,7 +39,9 @@ class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
}
}
class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
class EOSEFollowList(
var followList: Map<String, EOSERelayList> = emptyMap(),
) {
fun addOrUpdate(
listCode: String,
relayUrl: String,
@@ -54,7 +58,9 @@ class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
}
}
class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
class EOSEAccount(
var users: Map<User, EOSEFollowList> = emptyMap(),
) {
fun addOrUpdate(
user: User,
listCode: String,
@@ -28,150 +28,152 @@ import androidx.lifecycle.LifecycleOwner
import java.lang.ref.WeakReference
import java.util.Locale
class TextToSpeechHelper private constructor(private val context: WeakReference<Context>) :
LifecycleEventObserver {
private val appContext
get() = context.get()!!.applicationContext
class TextToSpeechHelper private constructor(
private val context: WeakReference<Context>,
) : LifecycleEventObserver {
private val appContext
get() = context.get()!!.applicationContext
private var message: String? = null
private var message: String? = null
private var ttsEngine: TextToSpeechEngine? = null
private var ttsEngine: TextToSpeechEngine? = null
private var onStart: (() -> Unit)? = null
private var onStart: (() -> Unit)? = null
private var onDoneListener: (() -> Unit)? = null
private var onDoneListener: (() -> Unit)? = null
private var onErrorListener: ((String) -> Unit)? = null
private var onErrorListener: ((String) -> Unit)? = null
private var onHighlightListener: ((Pair<Int, Int>) -> Unit)? = null
private var onHighlightListener: ((Pair<Int, Int>) -> Unit)? = null
private var customActionForDestroy: (() -> Unit)? = null
private var customActionForDestroy: (() -> Unit)? = null
init {
Log.d("Init", "Init TTS")
init {
Log.d("Init", "Init TTS")
initTTS()
}
fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper {
owner.lifecycle.addObserver(this)
return this
}
private fun initTTS() =
context.get()?.run {
ttsEngine =
TextToSpeechEngine
.getInstance()
.setOnCompletionListener { onDoneListener?.invoke() }
.setOnErrorListener { onErrorListener?.invoke(it) }
.setOnStartListener { onStart?.invoke() }
}
fun speak(message: String): TextToSpeechHelper {
if (ttsEngine == null) {
initTTS()
}
this.message = message
fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper {
owner.lifecycle.addObserver(this)
return this
ttsEngine?.initTTS(
appContext,
message,
)
return this
}
/**
* This method will highlight the text in the textView
*
* @exception Exception("Message can't be null for highlighting !! Call speak() first")
*/
fun highlight(): TextToSpeechHelper {
if (message == null) {
throw Exception("Message can't be null for highlighting !! Call speak() first")
}
ttsEngine?.setHighlightedMessage(message!!)
ttsEngine?.setOnHighlightListener { i, i2 -> onHighlightListener?.invoke(Pair(i, i2)) }
return this
}
private fun initTTS() =
context.get()?.run {
ttsEngine =
TextToSpeechEngine.getInstance()
.setOnCompletionListener { onDoneListener?.invoke() }
.setOnErrorListener { onErrorListener?.invoke(it) }
.setOnStartListener { onStart?.invoke() }
}
fun removeHighlight(): TextToSpeechHelper {
message = null
onHighlightListener = null
return this
}
fun speak(message: String): TextToSpeechHelper {
if (ttsEngine == null) {
initTTS()
}
this.message = message
fun destroy(action: (() -> Unit) = {}) {
ttsEngine?.destroy()
ttsEngine = null
action.invoke()
instance = null
}
ttsEngine?.initTTS(
appContext,
message,
)
return this
}
fun onStart(onStartListener: () -> Unit): TextToSpeechHelper {
this.onStart = onStartListener
return this
}
/**
* This method will highlight the text in the textView
*
* @exception Exception("Message can't be null for highlighting !! Call speak() first")
*/
fun highlight(): TextToSpeechHelper {
if (message == null) {
throw Exception("Message can't be null for highlighting !! Call speak() first")
}
ttsEngine?.setHighlightedMessage(message!!)
ttsEngine?.setOnHighlightListener { i, i2 -> onHighlightListener?.invoke(Pair(i, i2)) }
return this
}
fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper {
this.onDoneListener = onCompleteListener
return this
}
fun removeHighlight(): TextToSpeechHelper {
message = null
onHighlightListener = null
return this
}
fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper {
this.onErrorListener = onErrorListener
return this
}
fun destroy(action: (() -> Unit) = {}) {
ttsEngine?.destroy()
ttsEngine = null
action.invoke()
instance = null
}
fun onHighlight(onHighlightListener: (Pair<Int, Int>) -> Unit): TextToSpeechHelper {
this.onHighlightListener = onHighlightListener
return this
}
fun onStart(onStartListener: () -> Unit): TextToSpeechHelper {
this.onStart = onStartListener
return this
}
fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper {
customActionForDestroy = action
return this
}
fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper {
this.onDoneListener = onCompleteListener
return this
}
fun setLanguage(locale: Locale): TextToSpeechHelper {
ttsEngine?.setLanguage(locale)
return this
}
fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper {
this.onErrorListener = onErrorListener
return this
}
fun setPitchAndSpeed(
pitch: Float = DEF_SPEECH_AND_PITCH,
speed: Float = DEF_SPEECH_AND_PITCH,
): TextToSpeechHelper {
ttsEngine?.setPitchAndSpeed(pitch, speed)
return this
}
fun onHighlight(onHighlightListener: (Pair<Int, Int>) -> Unit): TextToSpeechHelper {
this.onHighlightListener = onHighlightListener
return this
}
fun resetPitchAndSpeed(): TextToSpeechHelper {
ttsEngine?.resetPitchAndSpeed()
return this
}
fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper {
customActionForDestroy = action
return this
}
companion object {
private var instance: TextToSpeechHelper? = null
fun setLanguage(locale: Locale): TextToSpeechHelper {
ttsEngine?.setLanguage(locale)
return this
}
fun setPitchAndSpeed(
pitch: Float = DEF_SPEECH_AND_PITCH,
speed: Float = DEF_SPEECH_AND_PITCH,
): TextToSpeechHelper {
ttsEngine?.setPitchAndSpeed(pitch, speed)
return this
}
fun resetPitchAndSpeed(): TextToSpeechHelper {
ttsEngine?.resetPitchAndSpeed()
return this
}
companion object {
private var instance: TextToSpeechHelper? = null
fun getInstance(context: Context): TextToSpeechHelper {
synchronized(TextToSpeechHelper::class.java) {
if (instance == null) {
instance = TextToSpeechHelper(WeakReference(context))
}
return instance!!
fun getInstance(context: Context): TextToSpeechHelper {
synchronized(TextToSpeechHelper::class.java) {
if (instance == null) {
instance = TextToSpeechHelper(WeakReference(context))
}
}
}
override fun onStateChanged(
source: LifecycleOwner,
event: Lifecycle.Event,
) {
if (
event == Lifecycle.Event.ON_DESTROY ||
event == Lifecycle.Event.ON_STOP ||
event == Lifecycle.Event.ON_PAUSE
) {
destroy { customActionForDestroy?.invoke() }
return instance!!
}
}
}
override fun onStateChanged(
source: LifecycleOwner,
event: Lifecycle.Event,
) {
if (
event == Lifecycle.Event.ON_DESTROY ||
event == Lifecycle.Event.ON_STOP ||
event == Lifecycle.Event.ON_PAUSE
) {
destroy { customActionForDestroy?.invoke() }
}
}
}
@@ -30,6 +30,7 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
@@ -72,6 +73,7 @@ class MainActivity : AppCompatActivity() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
Log.d("Lifetime Event", "MainActivity.onCreate")
@@ -88,7 +88,8 @@ object ImageSaver {
val client = HttpClientManager.getHttpClient()
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.get()
.url(url)
@@ -49,6 +49,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.FileServersEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -189,8 +191,25 @@ fun ImageVideoPost(
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
) {
val listOfNip96ServersNote =
accountViewModel.account
.getFileServersNote()
.live()
.metadata
.observeAsState()
val fileServers =
Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } +
(
(listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map {
ServerOption(
Nip96MediaServers.ServerName(
it,
it,
),
false,
)
} ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) }
) +
listOf(
ServerOption(
Nip96MediaServers.ServerName(
@@ -168,6 +168,7 @@ 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.FileServersEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
@@ -629,17 +630,21 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
@Composable
private fun PollField(postViewModel: NewPostViewModel) {
val optionsList = postViewModel.pollOptions
Column(
modifier = Modifier.fillMaxWidth(),
) {
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index)
optionsList.forEach { value ->
NewPollOption(postViewModel, value.key)
}
NewPollVoteValueRange(postViewModel)
Button(
onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" },
onClick = {
// postViewModel.pollOptions[postViewModel.pollOptions.size] = ""
optionsList[optionsList.size] = ""
},
border =
BorderStroke(
1.dp,
@@ -1620,8 +1625,25 @@ fun ImageVideoDescription(
val isImage = mediaType.startsWith("image")
val isVideo = mediaType.startsWith("video")
val listOfNip96ServersNote =
accountViewModel.account
.getFileServersNote()
.live()
.metadata
.observeAsState()
val fileServers =
Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } +
(
(listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map {
ServerOption(
Nip96MediaServers.ServerName(
it,
it,
),
false,
)
} ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) }
) +
listOf(
ServerOption(
Nip96MediaServers.ServerName(
@@ -1278,10 +1278,26 @@ open class NewPostViewModel : ViewModel() {
}
fun removePollOption(optionIndex: Int) {
pollOptions.remove(optionIndex)
pollOptions.removeOrdered(optionIndex)
saveDraft()
}
private fun MutableMap<Int, String>.removeOrdered(index: Int) {
val keyList = keys
val elementList = values.toMutableList()
run stop@{
for (i in index until elementList.size) {
val nextIndex = i + 1
if (nextIndex == elementList.size) return@stop
elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" }
}
}
elementList.removeLast()
val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) }
this.clear()
this.putAll(newEntries)
}
fun updatePollOption(
optionIndex: Int,
text: String,
@@ -35,12 +35,15 @@ import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlin.coroutines.cancellation.CancellationException
data class RangesChanges(val original: TextRange, val modified: TextRange)
data class RangesChanges(
val original: TextRange,
val modified: TextRange,
)
class UrlUserTagTransformation(val color: Color) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return buildAnnotatedStringWithUrlHighlighting(text, color)
}
class UrlUserTagTransformation(
val color: Color,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText = buildAnnotatedStringWithUrlHighlighting(text, color)
}
fun buildAnnotatedStringWithUrlHighlighting(
@@ -0,0 +1,93 @@
/**
* 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.mediaServers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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 com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
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.HttpUrlFormatter
@Composable
fun MediaServerEditField(
modifier: Modifier = Modifier,
onAddServer: (String) -> Unit,
) {
var url by remember { mutableStateOf("") }
val validUrl by
remember {
derivedStateOf {
url.isNotBlank() && HttpUrlFormatter.isValidUrl(url)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement =
Arrangement.spacedBy(
Size10dp,
),
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.add_a_nip96_server)) },
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 != "/") {
onAddServer(HttpUrlFormatter.normalize(url))
url = ""
}
},
shape = ButtonBorder,
enabled = validUrl,
) {
Text(text = stringRes(id = R.string.add), color = Color.White)
}
}
}
@@ -0,0 +1,287 @@
/**
* 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.mediaServers
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.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.text.style.TextAlign
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.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaServersListView(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val mediaServersViewModel: MediaServersViewModel = viewModel()
val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle()
LaunchedEffect(key1 = Unit) {
mediaServersViewModel.load(accountViewModel.account)
}
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.media_servers),
style = MaterialTheme.typography.titleLarge,
)
}
},
navigationIcon = {
CloseButton(
onPress = {
mediaServersViewModel.refresh()
onClose()
},
)
},
actions = {
SaveButton(
onPost = {
mediaServersViewModel.saveFileServers()
onClose()
},
isActive = true,
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
start = 16.dp,
top = padding.calculateTopPadding(),
end = 16.dp,
bottom = padding.calculateBottomPadding(),
),
verticalArrangement = Arrangement.spacedBy(5.dp, alignment = Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
stringRes(id = R.string.set_preferred_media_servers),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.grayText,
)
LazyColumn(
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = FeedPadding,
) {
renderMediaServerList(
mediaServersState = mediaServersState,
onAddServer = { server ->
mediaServersViewModel.addServer(server)
},
onDeleteServer = {
mediaServersViewModel.removeServer(serverUrl = it)
},
)
Nip96MediaServers.DEFAULT.let {
item {
SettingsCategoryWithButton(
title = stringRes(id = R.string.built_in_media_servers_title),
description = stringRes(id = R.string.built_in_servers_description),
action = {
OutlinedButton(
onClick = {
mediaServersViewModel.addServerList(it.map { s -> s.baseUrl })
},
) {
Text(text = stringRes(id = R.string.use_default_servers))
}
},
)
}
itemsIndexed(
it,
key = { index: Int, server: Nip96MediaServers.ServerName ->
server.baseUrl
},
) { index, server ->
MediaServerEntry(
serverEntry = server,
isAmethystDefault = true,
onAddOrDelete = { serverUrl ->
mediaServersViewModel.addServer(serverUrl)
},
)
}
}
}
}
}
}
}
fun LazyListScope.renderMediaServerList(
mediaServersState: List<Nip96MediaServers.ServerName>,
onAddServer: (String) -> Unit,
onDeleteServer: (String) -> Unit,
) {
if (mediaServersState.isEmpty()) {
item {
Text(
text = stringRes(id = R.string.no_media_server_message),
modifier = DoubleVertPadding,
)
}
} else {
itemsIndexed(
mediaServersState,
key = { index: Int, server: Nip96MediaServers.ServerName ->
server.baseUrl
},
) { index, entry ->
MediaServerEntry(
serverEntry = entry,
onAddOrDelete = {
onDeleteServer(it)
},
)
}
}
item {
Spacer(modifier = StdVertSpacer)
MediaServerEditField {
onAddServer(it)
}
}
}
@Composable
fun MediaServerEntry(
modifier: Modifier = Modifier,
serverEntry: Nip96MediaServers.ServerName,
isAmethystDefault: Boolean = false,
onAddOrDelete: (serverUrl: String) -> Unit,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround,
) {
Column(
modifier =
Modifier
.weight(1f),
) {
serverEntry.let {
Text(
text = it.name.replaceFirstChar(Char::titlecase),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = StdVertSpacer)
Text(
text = it.baseUrl,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
Row(
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = {
onAddOrDelete(serverEntry.baseUrl)
},
) {
Icon(
imageVector = if (isAmethystDefault) Icons.Rounded.Add else Icons.Rounded.Delete,
contentDescription =
if (isAmethystDefault) {
stringRes(id = R.string.add_media_server)
} else {
stringRes(id = R.string.delete_media_server)
},
)
}
}
}
}
@@ -0,0 +1,127 @@
/**
* 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.mediaServers
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.czeal.rfc3986.URIReference
class MediaServersViewModel : ViewModel() {
lateinit var account: Account
private val _fileServers = MutableStateFlow<List<Nip96MediaServers.ServerName>>(emptyList())
val fileServers = _fileServers.asStateFlow()
private var isModified = false
fun load(account: Account) {
this.account = account
refresh()
}
fun refresh() {
isModified = false
_fileServers.update {
val obtainedFileServers = obtainFileServers() ?: emptyList()
obtainedFileServers.mapNotNull { serverUrl ->
try {
Nip96MediaServers
.ServerName(
URIReference.parse(serverUrl).host.value,
serverUrl,
)
} catch (e: Exception) {
Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list")
null
}
}
}
}
fun addServerList(serverList: List<String>) {
serverList.forEach { serverUrl ->
addServer(serverUrl)
}
}
fun addServer(serverUrl: String) {
val normalizedUrl =
try {
URIReference.parse(serverUrl.trim()).normalize().toString()
} catch (e: Exception) {
serverUrl
}
val serverNameReference =
try {
URIReference.parse(normalizedUrl).host.value
} catch (e: Exception) {
normalizedUrl
}
val serverRef = Nip96MediaServers.ServerName(serverNameReference, normalizedUrl)
if (_fileServers.value.contains(serverRef)) {
return
} else {
_fileServers.update {
it.plus(serverRef)
}
}
isModified = true
}
fun removeServer(
name: String = "",
serverUrl: String,
) {
viewModelScope.launch {
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
_fileServers.update {
it.minus(
Nip96MediaServers.ServerName(serverName, serverUrl),
)
}
isModified = true
}
}
fun removeAllServers() {
_fileServers.update { emptyList() }
isModified = true
}
fun saveFileServers() {
if (isModified) {
viewModelScope.launch(Dispatchers.IO) {
val serverList = _fileServers.value.map { it.baseUrl }
account.sendFileServersList(serverList)
refresh()
}
}
}
private fun obtainFileServers(): List<String>? = account.getFileServersList()?.servers()
}
@@ -38,8 +38,10 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -69,6 +71,7 @@ fun AllRelayListView(
) {
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
val kind3Proposals by kind3ViewModel.proposedRelays.collectAsStateWithLifecycle()
val dmViewModel: DMRelayListViewModel = viewModel()
val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle()
@@ -112,6 +115,8 @@ fun AllRelayListView(
Text(
text = stringRes(R.string.relay_settings),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
@@ -229,6 +234,16 @@ fun AllRelayListView(
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, onClose, nav, relayToAdd)
if (kind3Proposals.isNotEmpty()) {
item {
SettingsCategory(
stringRes(R.string.kind_3_recommended_section),
stringRes(R.string.kind_3_recommended_section_description),
)
}
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, onClose, nav)
}
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.RelayStat
import com.vitorpamplona.quartz.encoders.HexKey
@Immutable
data class BasicRelaySetupInfo(
@@ -45,3 +46,16 @@ data class Kind3BasicRelaySetupInfo(
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@Immutable
data class Kind3RelayProposalSetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val feedTypes: Set<FeedType>,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
val users: Set<HexKey>,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@@ -78,12 +78,15 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
_relays.update {
val relayList = getRelayList() ?: emptyList()
relayList.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
relayList
.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
}
}
@@ -21,9 +21,7 @@
package com.vitorpamplona.amethyst.ui.actions.relays
class DMRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.getDMRelayList()?.relays()
}
override fun getRelayList(): List<String>? = account.getDMRelayList()?.relays()
override fun saveRelayList(urlList: List<String>) {
account.saveDMRelayList(urlList)
@@ -143,6 +143,31 @@ fun LazyListScope.renderKind3Items(
}
}
fun LazyListScope.renderKind3ProposalItems(
feedState: List<Kind3RelayProposalSetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "kind3proposal" + item.url }) { index, item ->
Kind3RelaySetupInfoProposalDialog(
item = item,
onAdd = {
postViewModel.addRelay(item)
},
accountViewModel = accountViewModel,
nav = {
onClose()
nav(it)
},
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
@Preview
@Composable
fun ServerConfigPreview() {
@@ -30,6 +30,7 @@ import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -43,6 +44,9 @@ class Kind3RelayListViewModel : ViewModel() {
private val _relays = MutableStateFlow<List<Kind3BasicRelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
private val _proposedRelays = MutableStateFlow<List<Kind3RelayProposalSetupInfo>>(emptyList())
val proposedRelays = _proposedRelays.asStateFlow()
var hasModified = false
fun load(account: Account) {
@@ -127,6 +131,44 @@ class Kind3RelayListViewModel : ViewModel() {
.reversed()
}
}
refreshProposals()
}
private fun refreshProposals() {
_proposedRelays.update {
val proposed =
MinimumRelayListProcessor
.reliableRelaySetFor(
account.liveKind3Follows.value.users.mapNotNull {
account.getNIP65RelayList(it)
},
relayUrlsToIgnore =
_relays.value.mapNotNullTo(HashSet()) {
if (it.read && FeedType.FOLLOWS in it.feedTypes) {
it.url
} else {
null
}
},
hasOnionConnection = false,
).sortedByDescending { it.users.size }
proposed.mapNotNull {
if (it.requiredToNotMissEvents) {
Kind3RelayProposalSetupInfo(
url = RelayUrlFormatter.normalize(it.url),
read = true,
write = false,
feedTypes = setOf(FeedType.FOLLOWS),
relayStat = RelayStats.get(it.url),
users = it.users,
)
} else {
null
}
}
}
}
fun addAll(defaultRelays: Array<RelaySetupInfo>) {
@@ -153,16 +195,34 @@ class Kind3RelayListViewModel : ViewModel() {
_relays.update { it.plus(relay) }
refreshProposals()
hasModified = true
}
fun addRelay(relay: Kind3RelayProposalSetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update { it.plus(Kind3BasicRelaySetupInfo(relay.url, relay.read, relay.write, relay.feedTypes, relay.relayStat, relay.paidRelay)) }
refreshProposals()
hasModified = true
}
fun deleteRelay(relay: Kind3BasicRelaySetupInfo) {
_relays.update { it.minus(relay) }
refreshProposals()
hasModified = true
}
fun deleteAll() {
_relays.update { relays -> emptyList() }
refreshProposals()
hasModified = true
}
@@ -0,0 +1,116 @@
/**
* 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.FeatureSetType
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
@Composable
fun Kind3RelaySetupInfoProposalDialog(
item: Kind3RelayProposalSetupInfo,
onAdd: (Kind3RelayProposalSetupInfo) -> 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,
)
}
Kind3RelaySetupInfoProposalRow(
item = item,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onAdd = {
onAdd(item)
},
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 ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
}
accountViewModel.toast(
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
},
)
},
nav = nav,
)
}
@@ -0,0 +1,136 @@
/**
* 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.note.AddRelayButton
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Kind3RelaySetupInfoProposalRow(
item: Kind3RelayProposalSetupInfo,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
onAdd: () -> Unit,
onClick: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
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,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat.fillMaxWidth()) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.paidRelay) {
Icon(
imageVector = Icons.Default.Paid,
null,
modifier =
Modifier
.padding(start = 5.dp, top = 1.dp)
.size(14.dp),
tint = MaterialTheme.colorScheme.allGoodColor,
)
}
}
}
FlowRow(verticalArrangement = Arrangement.Center) {
item.users.forEach {
UserPicture(
userHex = it,
size = Size25dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
Column(
Modifier
.padding(start = 10.dp),
) {
AddRelayButton(onAdd)
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -21,9 +21,7 @@
package com.vitorpamplona.amethyst.ui.actions.relays
class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.localRelayServers.toList()
}
override fun getRelayList(): List<String>? = account.localRelayServers.toList()
override fun saveRelayList(urlList: List<String>) {
account.updateLocalRelayServers(urlList.toSet())
@@ -105,23 +105,29 @@ class Nip65RelayListViewModel : ViewModel() {
_homeRelays.update {
val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList()
relayList.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
relayList
.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
}
_notificationRelays.update {
val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList()
relayList.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
relayList
.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
}
}
@@ -21,9 +21,7 @@
package com.vitorpamplona.amethyst.ui.actions.relays
class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.getPrivateOutboxRelayList()?.relays()
}
override fun getRelayList(): List<String>? = account.getPrivateOutboxRelayList()?.relays()
override fun saveRelayList(urlList: List<String>) {
account.savePrivateOutboxRelayList(urlList)
@@ -100,7 +100,11 @@ fun RelayInformationDialog(
),
) {
Surface {
val color = mutableStateOf(Color.Transparent)
val color =
remember {
mutableStateOf(Color.Transparent)
}
val context = LocalContext.current
LazyColumn(
@@ -21,9 +21,7 @@
package com.vitorpamplona.amethyst.ui.actions.relays
class SearchRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.getSearchRelayList()?.relays()
}
override fun getRelayList(): List<String>? = account.getSearchRelayList()?.relays()
override fun saveRelayList(urlList: List<String>) {
account.saveSearchRelayList(urlList)
@@ -101,11 +101,11 @@ fun AudioWaveformReadOnly(
minHeight = MIN_SPIKE_HEIGHT,
maxHeight = canvasSize.height.coerceAtLeast(MIN_SPIKE_HEIGHT),
)
}
.map { animateFloatAsState(it, spikeAnimationSpec).value }
}.map { animateFloatAsState(it, spikeAnimationSpec).value }
Canvas(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.requiredHeight(48.dp)
.graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA)
.then(modifier),
@@ -161,8 +161,7 @@ private fun List<Int>.toDrawableAmplitudes(
AmplitudeType.Avg -> data.average()
AmplitudeType.Max -> data.max()
AmplitudeType.Min -> data.min()
}
.toFloat()
}.toFloat()
.coerceIn(minHeight, maxHeight)
}
return when {
@@ -198,9 +197,7 @@ internal fun <T> Iterable<T>.chunkToSize(
internal fun Iterable<Float>.normalize(
min: Float,
max: Float,
): List<Float> {
return map { (max - min) * ((it - min()) / (max() - min())) + min }
}
): List<Float> = map { (max - min) * ((it - min()) / (max() - min())) + min }
private fun Int.safeDiv(value: Int): Float {
return if (value == 0) return 0F else this / value.toFloat()
@@ -26,9 +26,13 @@ import androidx.compose.runtime.Immutable
sealed class GenericLoadable<T> {
@Immutable class Loading<T> : GenericLoadable<T>()
@Immutable class Loaded<T>(val loaded: T) : GenericLoadable<T>()
@Immutable class Loaded<T>(
val loaded: T,
) : GenericLoadable<T>()
@Immutable class Empty<T> : GenericLoadable<T>()
@Immutable class Error<T>(val errorMessage: String) : GenericLoadable<T>()
@Immutable class Error<T>(
val errorMessage: String,
) : GenericLoadable<T>()
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.util.Base64
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -28,6 +29,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -47,6 +49,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -61,7 +65,12 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImagePainter
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
@@ -74,6 +83,7 @@ import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.Segment
@@ -86,6 +96,7 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -101,6 +112,7 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.withContext
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
@@ -433,6 +445,47 @@ private fun RenderWordWithPreview(
is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ImageFromBase64(word.segmentText)
}
}
@Composable
fun ImageFromBase64(base64String: String) {
val context = LocalContext.current
var imageBytes by remember { mutableStateOf<ByteArray?>(null) }
LaunchedEffect(base64String) {
imageBytes =
withContext(Dispatchers.IO) {
var base64String2 = base64String.removePrefix("data:image/jpeg;base64,")
RichTextParser.imageExtensions.forEach {
base64String2 = base64String2.removePrefix("data:image/$it;base64,")
}
runCatching { Base64.decode(base64String2, Base64.DEFAULT) }.getOrNull()
}
}
if (imageBytes == null) {
BlankNote()
} else {
val request =
ImageRequest
.Builder(context)
.data(imageBytes)
.build()
SubcomposeAsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
) {
when (painter.state) {
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent()
}
else -> BlankNote()
}
}
}
}
@@ -25,12 +25,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlin.math.abs
class SplitItem<T>(val key: T) {
class SplitItem<T>(
val key: T,
) {
// 0 to 1
var percentage by mutableStateOf(0f)
}
class Split<T>() {
class Split<T> {
var items: List<SplitItem<T>> by mutableStateOf(emptyList())
fun addItem(
@@ -27,9 +27,13 @@ import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
sealed class UrlPreviewState {
@Immutable object Loading : UrlPreviewState()
@Immutable class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState()
@Immutable class Loaded(
val previewInfo: UrlInfoItem,
) : UrlPreviewState()
@Immutable object Empty : UrlPreviewState()
@Immutable class Error(val errorMessage: String) : UrlPreviewState()
@Immutable class Error(
val errorMessage: String,
) : UrlPreviewState()
}
@@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -204,6 +205,7 @@ fun VideoView(
title: String? = null,
thumb: VideoThumb? = null,
roundedCorner: Boolean,
gallery: Boolean = false,
isFiniteHeight: Boolean,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
@@ -247,6 +249,7 @@ fun VideoView(
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
gallery = gallery,
isFiniteHeight = isFiniteHeight,
waveform = waveform,
artworkUri = artworkUri,
@@ -324,6 +327,7 @@ fun VideoViewInner(
title: String? = null,
thumb: VideoThumb? = null,
roundedCorner: Boolean,
gallery: Boolean = false,
isFiniteHeight: Boolean,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
@@ -348,6 +352,7 @@ fun VideoViewInner(
controller = controller,
thumbData = thumb,
roundedCorner = roundedCorner,
gallery = gallery,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = nostrUriCallback,
waveform = waveform,
@@ -695,6 +700,7 @@ private fun RenderVideoPlayer(
controller: MediaController,
thumbData: VideoThumb?,
roundedCorner: Boolean,
gallery: Boolean = false,
isFiniteHeight: Boolean,
nostrUriCallback: String?,
waveform: ImmutableList<Int>? = null,
@@ -712,13 +718,18 @@ private fun RenderVideoPlayer(
Box {
val borders = MaterialTheme.colorScheme.imageModifier
val bordersSquare = MaterialTheme.colorScheme.videoGalleryModifier
val myModifier =
remember(controller) {
if (roundedCorner) {
modifier.then(
borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
)
} else if (gallery) {
Modifier
modifier.then(
bordersSquare.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
)
} else {
modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp).align(Alignment.Center)
}
@@ -737,6 +748,7 @@ private fun RenderVideoPlayer(
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = !gallery
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
@@ -745,72 +757,77 @@ private fun RenderVideoPlayer(
} else {
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controller.pause()
innerOnDialog(it)
if (!gallery) {
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controller.pause()
innerOnDialog(it)
}
}
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
},
)
}
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
},
)
}
},
)
waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) }
if (!gallery) {
val startingMuteState = remember(controller) { controller.volume < 0.001 }
val startingMuteState = remember(controller) { controller.volume < 0.001 }
MuteButton(
controllerVisible,
startingMuteState,
Modifier.align(Alignment.TopEnd),
) { mute: Boolean ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute
MuteButton(
controllerVisible,
startingMuteState,
Modifier.align(Alignment.TopEnd),
) { mute: Boolean ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute
// if the user unmutes a video and it's not the current playing, switches to that one.
if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) {
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
keepPlayingMutex = null
}
controller.volume = if (mute) 0f else 1f
}
KeepPlayingButton(
keepPlaying,
controllerVisible,
Modifier.align(Alignment.TopEnd).padding(end = Size55dp),
) { newKeepPlaying: Boolean ->
// If something else is playing and the user marks this video to keep playing, stops the other
// one.
if (newKeepPlaying) {
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
// if the user unmutes a video and it's not the current playing, switches to that one.
if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) {
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
}
keepPlayingMutex = controller
} else {
if (keepPlayingMutex == controller) {
keepPlayingMutex = null
}
controller.volume = if (mute) 0f else 1f
}
keepPlaying.value = newKeepPlaying
}
KeepPlayingButton(
keepPlaying,
controllerVisible,
Modifier.align(Alignment.TopEnd).padding(end = Size55dp),
) { newKeepPlaying: Boolean ->
// If something else is playing and the user marks this video to keep playing, stops the other
// one.
if (newKeepPlaying) {
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
}
keepPlayingMutex = controller
} else {
if (keepPlayingMutex == controller) {
keepPlayingMutex = null
}
}
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
saveImage(videoUri, mimeType, context, accountViewModel)
}
keepPlaying.value = newKeepPlaying
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(popupExpanded, videoUri, nostrUriCallback, toggle)
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
saveImage(videoUri, mimeType, context, accountViewModel)
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, toggle)
}
} else {
controller.volume = 0f
}
}
}
@@ -269,7 +269,7 @@ private fun DialogContent(
contentDescription = stringRes(R.string.quick_action_share),
)
ShareImageAction(popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
ShareImageAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
}
val localContext = LocalContext.current
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -103,6 +104,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
@Composable
fun ZoomableContentView(
@@ -179,15 +181,11 @@ fun TwoSecondController(
content: BaseMediaContent,
inner: @Composable (controllerVisible: MutableState<Boolean>) -> Unit,
) {
val controllerVisible = remember { mutableStateOf(true) }
val controllerVisible = remember(content) { mutableStateOf(true) }
LaunchedEffect(content) {
launch(Dispatchers.Default) {
delay(2000)
withContext(Dispatchers.Main) {
controllerVisible.value = false
}
}
delay(2.seconds)
controllerVisible.value = false
}
inner(controllerVisible)
@@ -596,12 +594,14 @@ fun DisplayBlurHash(
@Composable
fun ShareImageAction(
accountViewModel: AccountViewModel,
popupExpanded: MutableState<Boolean>,
content: BaseMediaContent,
onDismiss: () -> Unit,
) {
if (content is MediaUrlContent) {
ShareImageAction(
accountViewModel = accountViewModel,
popupExpanded = popupExpanded,
videoUri = content.url,
postNostrUri = content.uri,
@@ -609,6 +609,7 @@ fun ShareImageAction(
)
} else if (content is MediaPreloadedContent) {
ShareImageAction(
accountViewModel = accountViewModel,
popupExpanded = popupExpanded,
videoUri = content.localFile?.toUri().toString(),
postNostrUri = content.uri,
@@ -620,6 +621,7 @@ fun ShareImageAction(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ShareImageAction(
accountViewModel: AccountViewModel,
popupExpanded: MutableState<Boolean>,
videoUri: String?,
postNostrUri: String?,
@@ -650,6 +652,23 @@ fun ShareImageAction(
},
)
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_media_to_gallery)) },
onClick = {
if (videoUri != null) {
var n19 = Nip19Bech32.uriToRoute(postNostrUri)?.entity as? Nip19Bech32.NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0]) // TODO Whole list or first?
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
}
}
onDismiss()
},
)
}
}
}
@@ -24,10 +24,10 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
class BookmarkPrivateFeedFilter(val account: Account) : FeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().latestBookmarkList?.id ?: ""
}
class BookmarkPrivateFeedFilter(
val account: Account,
) : FeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().latestBookmarkList?.id ?: ""
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
@@ -24,10 +24,10 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
class BookmarkPublicFeedFilter(val account: Account) : FeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().latestBookmarkList?.id ?: ""
}
class BookmarkPublicFeedFilter(
val account: Account,
) : FeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().latestBookmarkList?.id ?: ""
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
@@ -24,27 +24,24 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return channel.idHex
}
class ChannelFeedFilter(
val channel: Channel,
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = channel.idHex
// returns the last Note of each user.
override fun feed(): List<Note> {
return sort(
override fun feed(): List<Note> =
sort(
channel.notes.filterIntoSet { key, it ->
account.isAcceptable(it)
},
)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return collection
override fun applyFilter(collection: Set<Note>): Set<Note> =
collection
.filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) }
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(DefaultFeedOrder)
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}
@@ -24,12 +24,12 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.ChatroomKey
class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) :
AdditiveFeedFilter<Note>() {
class ChatroomFeedFilter(
val withUser: ChatroomKey,
val account: Account,
) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feedKey(): String {
return withUser.hashCode().toString()
}
override fun feedKey(): String = withUser.hashCode().toString()
override fun feed(): List<Note> {
val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList()
@@ -46,7 +46,5 @@ class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) :
return collection.filter { it in messages.roomMessages && account.isAcceptable(it) }.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(DefaultFeedOrder)
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}
@@ -28,10 +28,10 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
class ChatroomListKnownFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
// returns the last Note of each user.
override fun feed(): List<Note> {
@@ -56,7 +56,8 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
.selectedChatsFollowList()
.mapNotNull { LocalCache.getChannelIfExists(it) }
.mapNotNull { it ->
it.notes.filter { key, it -> account.isAcceptable(it) && it.event != null }
it.notes
.filter { key, it -> account.isAcceptable(it) && it.event != null }
.sortedWith(DefaultFeedOrder)
.firstOrNull()
}
@@ -136,7 +137,11 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
account: Account,
): MutableMap<String, Note> {
val followingChannels =
account.userProfile().latestContactList?.taggedEvents()?.toSet() ?: emptySet()
account
.userProfile()
.latestContactList
?.taggedEvents()
?.toSet() ?: emptySet()
val newRelevantPublicMessages = mutableMapOf<String, Note>()
newItems
.filter { it.event is ChannelMessageEvent }
@@ -177,7 +182,8 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
newNote.author?.pubkeyHex == me.pubkeyHex ||
room.senderIntersects(followingKeySet) ||
me.hasSentMessagesTo(roomKey)
) && !account.isAllHidden(roomKey.users)
) &&
!account.isAllHidden(roomKey.users)
) {
val lastNote = newRelevantPrivateMessages.get(roomKey)
if (lastNote != null) {
@@ -193,7 +199,5 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
return newRelevantPrivateMessages
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(DefaultFeedOrder)
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}
@@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.PrivateDmEvent
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
class ChatroomListNewFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
// returns the last Note of each user.
override fun feed(): List<Note> {
@@ -137,7 +137,5 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
return newRelevantPrivateMessages
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(DefaultFeedOrder)
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}
@@ -27,10 +27,11 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
class CommunityFeedFilter(val note: AddressableNote, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + note.idHex
}
class CommunityFeedFilter(
val note: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + note.idHex
override fun feed(): List<Note> {
val myPubKey = account.userProfile().pubkeyHex
@@ -42,23 +43,23 @@ class CommunityFeedFilter(val note: AddressableNote, val account: Account) : Add
return sort(result)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myPubKey = account.userProfile().pubkeyHex
return collection.mapNotNull {
filterMap(it, myPubKey)
}.flatten().toSet()
return collection
.mapNotNull {
filterMap(it, myPubKey)
}.flatten()
.toSet()
}
private fun filterMap(
note: Note,
myPubKey: HexKey,
): List<Note>? {
return if (
): List<Note>? =
if (
// Only Approvals
note.event is CommunityPostApprovalEvent &&
// Of the given community
@@ -74,9 +75,6 @@ class CommunityFeedFilter(val note: AddressableNote, val account: Account) : Add
} else {
null
}
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(DefaultFeedOrder)
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}
@@ -29,17 +29,16 @@ import com.vitorpamplona.quartz.events.IsInPublicChatChannel
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
}
open class DiscoverChatFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean {
return account.defaultDiscoveryFollowList.value ==
override fun showHiddenKey(): Boolean =
account.defaultDiscoveryFollowList.value ==
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultDiscoveryFollowList.value ==
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
override fun feed(): List<Note> {
val params = buildFilterParams(account)
@@ -63,18 +62,15 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
return sort(allChannelNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultDiscoveryFollowList.value,
followLists = account.liveDiscoveryFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
@@ -120,7 +116,6 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
{ it.createdAt() },
{ it.idHex },
),
)
.reversed()
).reversed()
}
}
@@ -29,17 +29,16 @@ import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
}
open class DiscoverCommunityFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean {
return account.defaultDiscoveryFollowList.value ==
override fun showHiddenKey(): Boolean =
account.defaultDiscoveryFollowList.value ==
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultDiscoveryFollowList.value ==
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
override fun feed(): List<Note> {
val filterParams =
@@ -67,9 +66,7 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
// here, we need to look for CommunityDefinition in new collection AND new CommunityDefinition from Post Approvals
@@ -81,28 +78,30 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
hiddenUsers = account.flowHiddenUsers.value,
)
return collection.mapNotNull { note ->
// note event here will never be null
val noteEvent = note.event
if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) {
listOf(note)
} else if (noteEvent is CommunityPostApprovalEvent) {
noteEvent.communities().mapNotNull {
val definitionNote = LocalCache.getOrCreateAddressableNote(it)
val definitionEvent = definitionNote.event
return collection
.mapNotNull { note ->
// note event here will never be null
val noteEvent = note.event
if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) {
listOf(note)
} else if (noteEvent is CommunityPostApprovalEvent) {
noteEvent.communities().mapNotNull {
val definitionNote = LocalCache.getOrCreateAddressableNote(it)
val definitionEvent = definitionNote.event
if (definitionEvent == null && shouldInclude(it, filterParams)) {
definitionNote
} else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent)) {
definitionNote
} else {
null
if (definitionEvent == null && shouldInclude(it, filterParams)) {
definitionNote
} else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent)) {
definitionNote
} else {
null
}
}
} else {
null
}
} else {
null
}
}.flatten().toSet()
}.flatten()
.toSet()
}
private fun shouldInclude(
@@ -123,7 +122,6 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
{ it.createdAt() },
{ it.idHex },
),
)
.reversed()
).reversed()
}
}
@@ -34,18 +34,13 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
open class DiscoverLiveFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + followList()
}
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
open fun followList(): String {
return account.defaultDiscoveryFollowList.value
}
open fun followList(): String = account.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean {
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
override fun showHiddenKey(): Boolean =
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
override fun feed(): List<Note> {
val allChannelNotes = LocalCache.channels.mapNotNull { _, channel -> LocalCache.getNoteIfExists(channel.idHex) }
@@ -56,9 +51,7 @@ open class DiscoverLiveFeedFilter(
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams =
@@ -92,16 +85,14 @@ open class DiscoverLiveFeedFilter(
{ (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() },
{ it.idHex },
),
)
.reversed()
).reversed()
}
fun convertStatusToOrder(status: String?): Int {
return when (status) {
fun convertStatusToOrder(status: String?): Int =
when (status) {
STATUS_LIVE -> 2
STATUS_PLANNED -> 1
STATUS_ENDED -> 0
else -> 0
}
}
}
@@ -30,18 +30,13 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
open class DiscoverMarketplaceFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + followList()
}
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
open fun followList(): String {
return account.defaultDiscoveryFollowList.value
}
open fun followList(): String = account.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean {
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
override fun showHiddenKey(): Boolean =
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
override fun feed(): List<Note> {
val params = buildFilterParams(account)
@@ -55,18 +50,15 @@ open class DiscoverMarketplaceFeedFilter(
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.userProfile().pubkeyHex,
account.defaultDiscoveryFollowList.value,
account.liveDiscoveryFollowLists.value,
account.flowHiddenUsers.value,
)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
@@ -77,7 +69,5 @@ open class DiscoverMarketplaceFeedFilter(
}
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
@@ -34,18 +34,13 @@ open class DiscoverNIP89FeedFilter(
val lastAnnounced = 90 * 24 * 60 * 60 // 90 Days ago
// TODO better than announced would be last active, as this requires the DVM provider to regularly update the NIP89 announcement
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + followList()
}
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
open fun followList(): String {
return account.defaultDiscoveryFollowList.value
}
open fun followList(): String = account.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean {
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
override fun showHiddenKey(): Boolean =
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
@@ -58,18 +53,15 @@ open class DiscoverNIP89FeedFilter(
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
fun buildFilterParams(account: Account): FilterByListParams {
return FilterByListParams.create(
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.userProfile().pubkeyHex,
account.defaultDiscoveryFollowList.value,
account.liveDiscoveryFollowLists.value,
account.flowHiddenUsers.value,
)
}
fun acceptDVM(note: Note): Boolean {
val noteEvent = note.event
@@ -88,13 +80,10 @@ open class DiscoverNIP89FeedFilter(
noteEvent.createdAt > TimeUtils.now() - lastAnnounced // && params.match(noteEvent)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
return collection.filterTo(HashSet()) {
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
collection.filterTo(HashSet()) {
acceptDVM(it)
}
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
@@ -25,16 +25,15 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.DraftEvent
class DraftEventsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
class DraftEventsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun applyFilter(collection: Set<Note>): Set<Note> {
return collection.filterTo(HashSet()) {
override fun applyFilter(collection: Set<Note>): Set<Note> =
collection.filterTo(HashSet()) {
acceptableEvent(it)
}
}
override fun feed(): List<Note> {
val drafts =
@@ -50,7 +49,5 @@ class DraftEventsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
return noteEvent is DraftEvent && noteEvent.pubKey == account.userProfile().pubkeyHex
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(DefaultFeedOrder)
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}
@@ -91,13 +91,12 @@ class FilterByListParams(
selectedListName: String,
followLists: Account.LiveFollowLists?,
hiddenUsers: Account.LiveHiddenUsers,
): FilterByListParams {
return FilterByListParams(
): FilterByListParams =
FilterByListParams(
isGlobal = selectedListName == GLOBAL_FOLLOWS,
isHiddenList = showHiddenKey(selectedListName, userHex),
followLists = followLists,
hiddenLists = hiddenUsers,
)
}
}
}

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