Compare commits

..
179 Commits
Author SHA1 Message Date
Vitor Pamplona fbb036da4a v0.93.0 2024-11-27 10:33:59 -05:00
Vitor Pamplona 9a0c998654 Fixes "null" strings on profile fields. 2024-11-27 10:24:09 -05:00
Vitor Pamplona 88082b1ba3 Updates media and coil 2024-11-27 09:19:17 -05:00
Vitor PamplonaandGitHub ad2b2e6d02 Merge pull request #1193 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-27 09:16:25 -05:00
Crowdin Bot 5044a687fa New Crowdin translations by GitHub Action 2024-11-27 14:02:42 +00:00
Vitor Pamplona 298d4bb237 removes unnecessary metadata 2024-11-27 08:59:23 -05:00
Vitor PamplonaandGitHub 9c180cb87a Merge pull request #1192 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-26 19:10:54 -05:00
Crowdin Bot 2e3f2044d5 New Crowdin translations by GitHub Action 2024-11-27 00:09:32 +00:00
Vitor Pamplona bdf012f641 Adds support for Interactive Stories 2024-11-26 19:07:12 -05:00
Vitor Pamplona 034ee46543 Refactors additional factories 2024-11-26 09:28:42 -05:00
Vitor PamplonaandGitHub 6a17f94cb1 Merge pull request #1190 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-25 14:31:56 -05:00
Crowdin Bot 233059d544 New Crowdin translations by GitHub Action 2024-11-25 19:31:18 +00:00
Vitor Pamplona 7bd9b6a1ec Moves to the new Android Image/Video Picker 2024-11-25 14:24:17 -05:00
Vitor Pamplona 04e77daaab Light chat bubbles 2024-11-22 19:17:50 -05:00
Vitor Pamplona 308048c352 Adds date separators on chats 2024-11-22 18:08:30 -05:00
Vitor Pamplona 3f1173a42a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-22 17:01:57 -05:00
Vitor Pamplona 39726142ae Fixes account creation that follows itself. 2024-11-22 17:01:50 -05:00
Vitor PamplonaandGitHub 025cbeed07 Merge pull request #1189 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-22 16:33:33 -05:00
Crowdin Bot 8a1550cbd9 New Crowdin translations by GitHub Action 2024-11-22 21:20:34 +00:00
Vitor Pamplona 49148ddc76 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-22 16:18:01 -05:00
Vitor Pamplona d0e870e1b8 Adds Custom Empty Feed Screen for notifications 2024-11-22 16:17:55 -05:00
Vitor PamplonaandGitHub dbb2bfa512 Merge pull request #1188 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-22 12:26:35 -05:00
Crowdin Bot d6b66c65fa New Crowdin translations by GitHub Action 2024-11-22 16:36:21 +00:00
Vitor Pamplona 9a3d365243 Fixes: https://github.com/vitorpamplona/amethyst/issues/1186 2024-11-22 11:33:51 -05:00
Vitor PamplonaandGitHub 3b30d5493d Merge pull request #1184 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-21 18:58:17 -05:00
Crowdin Bot 7bfd1a9107 New Crowdin translations by GitHub Action 2024-11-21 23:53:47 +00:00
Vitor Pamplona 2c9e2de524 Adds support for Blossom media servers. 2024-11-21 18:51:43 -05:00
Vitor Pamplona c89c5eb4b0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-21 15:46:16 -05:00
Vitor Pamplona 5c5eb53395 Fixes translations of http urls 2024-11-21 15:46:07 -05:00
Vitor PamplonaandGitHub 55f0092e03 Merge pull request #1185 from davotoula/enable-unfollow-in-note-dropdown
Enable unfollow in note dropdown
2024-11-21 15:32:44 -05:00
Vitor Pamplona 54d9ac5730 no need for identifier anymore 2024-11-21 15:15:03 -05:00
David Kaspar d9a9137d89 cleaner code 2024-11-21 21:02:31 +01:00
David Kaspar 7beff8c835 Add unfollow to NoteDropDownMenu 2024-11-21 20:58:40 +01:00
Vitor Pamplona 4a80a16473 Adds support for pronouns when the user makes them available. 2024-11-21 11:19:13 -05:00
Vitor Pamplona b3e3b70dd0 Marks which events can receive Comment replies as root scopes. 2024-11-19 18:01:56 -05:00
Vitor Pamplona 03662b9d1b Migrates Video feed new posts from NIP-94 to Video and Picture events. 2024-11-19 17:54:51 -05:00
Vitor Pamplona d33421fce4 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-19 15:37:11 -05:00
Vitor Pamplona 4db6f5531c Adds support for Instagram feeds. https://github.com/nostr-protocol/nips/pull/1551 2024-11-19 15:36:30 -05:00
Vitor PamplonaandGitHub 72ea4a44a2 Merge pull request #1183 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-19 09:31:20 -05:00
Crowdin Bot 022a9f6f6c New Crowdin translations by GitHub Action 2024-11-19 14:25:24 +00:00
Vitor Pamplona 248848799c Fully Deprecates note1, removing the last usages of the standard 2024-11-19 09:22:37 -05:00
Vitor Pamplona 72f317717b Remove the unused encryption field from NIP-94 2024-11-18 17:29:59 -05:00
Vitor Pamplona 4651074774 Fixes bug on searching for geohashes on addresses 2024-11-18 16:48:21 -05:00
Vitor Pamplona fb058f93b6 Makes NIP-22 posts only visible to the main feed if inside the scope they were placed. 2024-11-18 16:47:47 -05:00
Vitor Pamplona 9e34e9d720 Fixes issue with the order of same-author events in a thread 2024-11-18 16:00:48 -05:00
Vitor Pamplona 04d6da28af Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-11-18 15:39:19 -05:00
Vitor Pamplona 192a43dc21 Updates compose, coil, firebase and navigation 2024-11-18 15:39:12 -05:00
Vitor Pamplona 61510ae7e3 Adds support for NIP-22 exclusive geo posts 2024-11-18 15:38:57 -05:00
Vitor PamplonaandGitHub 488286ce20 Merge pull request #1181 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-18 08:25:31 -05:00
Crowdin Bot fab6c65e98 New Crowdin translations by GitHub Action 2024-11-18 13:22:15 +00:00
Vitor PamplonaandGitHub accde8622c Merge pull request #1179 from davotoula/1172-hide-drafts-from-other-accounts-on-device
Hide drafts from other accounts on device
2024-11-18 08:20:55 -05:00
David Kaspar 4a590d67a6 move filtering of "not your drafts" to ThreadFeedFilter 2024-11-17 10:48:03 +01:00
David Kaspar 6c3200a9cc cleaner code, change cascading ifs to when 2024-11-16 10:34:01 +01:00
David Kaspar f16143bec3 cleaner code, fewer warnings 2024-11-16 09:29:45 +01:00
David Kaspar 8216476916 cleaner code, fewer warnings 2024-11-16 08:48:30 +01:00
David Kaspar ffd43e448b filter out drafts that are not same author as current account 2024-11-15 12:54:59 +01:00
Vitor Pamplona 3a566dad6b Improves (Show More) presence to only when it actually makes a difference. 2024-11-12 13:36:59 -05:00
Vitor Pamplona bf861d6bc5 - Improves thread preloading
- Fixes jumping of scroll when the thread updates
2024-11-12 12:54:41 -05:00
Vitor Pamplona b31ace79c3 App hangs when switching to an account that was logged into with the npub just because it waits to decrypt the hidden user list. 2024-11-11 18:29:38 -05:00
Vitor Pamplona 37a92c25f0 - Unifies Location Flows and GeoHash Flows into one
- Make them react to changing location permissions
- Adds UI for when the location permission is rejected.
2024-11-11 17:51:42 -05:00
Vitor Pamplona 062e4af118 Updates JNA, Coil, Jackson, and Kotlin Collections 2024-11-11 14:54:38 -05:00
Vitor Pamplona c083295f6a Fixes error message for the signup page when the display name is blank 2024-11-11 14:53:39 -05:00
Vitor PamplonaandGitHub 924baf0de3 Merge pull request #1177 from greenart7c3/main
Fix initial decryption of mute lists when using amber
2024-11-11 09:48:46 -05:00
greenart7c3andgreenart7c3 6015c968f5 Fix initial decryption of mutelists when using amber 2024-11-11 11:33:14 -03:00
Vitor PamplonaandGitHub 3e85991f19 Merge pull request #1176 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-11 09:04:33 -05:00
Crowdin Bot 893b23e4fb New Crowdin translations by GitHub Action 2024-11-11 13:54:10 +00:00
Vitor PamplonaandGitHub cb5d69e358 Merge pull request #1171 from davotoula/crashfix-test-for-empty-string-in-mentions
Crashfix: test for empty string in mentions
2024-11-11 08:52:39 -05:00
Vitor PamplonaandGitHub 43688c4117 Merge pull request #1174 from davotoula/1173-add-nip05-to-hidden-words-filtering
Added nip05 field from user profile to be used as part of hidden words filtering
2024-11-11 08:52:28 -05:00
David Kaspar 4e4521ecef Added nip05 field from user profile to be used as part of hidden words filtering 2024-11-10 06:51:34 +00:00
David Kaspar 7bc9205758 Added a test around NewPostViewModel.load with no mentions, valid mentions and invalid empty mentions 2024-11-08 19:15:04 +00:00
David Kaspar abf6cc00ae test for empty string in mentions 2024-11-07 09:24:30 +00:00
Vitor Pamplona 7146838f63 Better names for github jobs 2024-11-04 11:56:25 -05:00
Vitor Pamplona 1037ab33e3 fixes names of uploaded files 2024-11-04 11:31:04 -05:00
Vitor Pamplona 230fd553ae fixes paths on github actions 2024-11-04 11:12:40 -05:00
Vitor Pamplona eac078c42d updates build github action to test and upload the right assets 2024-11-04 10:54:14 -05:00
Vitor Pamplona 39c9b75bf4 Moves zapstore to local image and first in the list 2024-11-04 10:15:31 -05:00
Vitor PamplonaandGitHub e076a2dbe3 Merge pull request #1167 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-04 10:12:31 -05:00
Crowdin Bot 5705a26836 New Crowdin translations by GitHub Action 2024-11-04 15:12:18 +00:00
Vitor PamplonaandGitHub 740c279825 Merge pull request #1165 from davotoula/1164-launch-in-scope-to-persist-translation-prefs
Crash fix: Launch in Dispatchers.IO when changing translations prefs in account
2024-11-04 10:10:48 -05:00
Vitor PamplonaandGitHub 718605e44b Merge pull request #1163 from ilcompratoreconsapevole/banner
Added ZapStore's SVG banner that says "Get it on" linking to its repo
2024-11-04 10:09:49 -05:00
Vitor PamplonaandGitHub 8df2ff839c Merge pull request #1162 from davotoula/bugfix-images-reported-saved-as-video
Bugfix: images reported saved as video
2024-11-04 10:09:36 -05:00
ilcompratoreconsapevole 00be9fc595 Rewritten connection link to ZapStore's banner image to ensure reliable connection to specific repo 2024-11-03 21:38:01 +01:00
David Kaspar 7d25ab13a9 Launch in Dispatchers.IO when changing translations prefs in account 2024-11-03 16:15:54 +00:00
ilcompratoreconsapevole d7888096e9 Added ZapStore's SVG banner that says "Get it on" linking to its repository. 2024-11-03 12:50:46 +01:00
David Kaspar aad5aaa930 rename inner it to avoid shadowed it 2024-11-03 11:48:12 +00:00
David Kaspar b877e4105a Replace nested ifs with a when 2024-11-03 10:30:07 +00:00
David Kaspar d1cd15c389 Remove unused val 2024-11-03 10:24:20 +00:00
David Kaspar 69e139e382 Remove unused @OptIn(ExperimentalFoundationApi::class) 2024-11-03 10:23:32 +00:00
David Kaspar b2628764c9 Remove unused shadowed orientation val 2024-11-03 10:20:30 +00:00
David Kaspar f7f0d74be1 Correct boolean operation from AND to OR 2024-11-03 10:19:29 +00:00
Vitor Pamplona b47122d79a Updates accompanist, kotlin serialization and mockk 2024-11-01 18:41:00 -04:00
Vitor Pamplona 635ad30de9 Let it throw in the giftwrap parser to make sure Notifications work 2024-11-01 18:40:34 -04:00
Vitor PamplonaandGitHub 0884318a55 Merge pull request #1161 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-01 18:27:38 -04:00
Crowdin Bot d8c89317d2 New Crowdin translations by GitHub Action 2024-11-01 22:20:09 +00:00
Vitor Pamplona 772987dffd coroutines test to 1.9 2024-11-01 18:17:32 -04:00
Vitor Pamplona 61c51ee9a8 Updates AGP 2024-11-01 18:17:07 -04:00
Vitor Pamplona f157f8eb9b - Fixes lack of zap updates when there is a private zap the user cannot decrypt or when a nwc payment fails.
- Moves the parallel processing to a utils class
2024-11-01 18:16:57 -04:00
Vitor Pamplona d96f5953d5 Moving animation size to a constant. 2024-11-01 17:27:19 -04:00
Vitor PamplonaandGitHub ffb5cce509 Merge pull request #1160 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-11-01 10:35:40 -04:00
Crowdin Bot b441292413 New Crowdin translations by GitHub Action 2024-11-01 14:16:09 +00:00
Vitor Pamplona 91748d5c39 - Adds a cache for reply levels when viewing threads.
- Moves the lazy list state to the view model to saving the scrolling position when navigating between multiple threads.
2024-11-01 10:10:05 -04:00
Vitor Pamplona a825f575de Fixes rendering of notes when the modifier changes due to new posts found and the reply level gets recalculated. 2024-11-01 10:06:09 -04:00
Vitor Pamplona 0581a3e836 Fixes reply level calculation caching. 2024-11-01 10:05:25 -04:00
Vitor Pamplona 82f0ed8a0d Upgrades to Coil 3 2024-10-31 16:34:22 -04:00
Vitor PamplonaandGitHub 116bc108c3 Merge pull request #1158 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-31 14:07:13 -04:00
Crowdin Bot 44868ac4e6 New Crowdin translations by GitHub Action 2024-10-31 18:05:03 +00:00
Vitor Pamplona 2a24ca0787 Adds support for q tags on addresses 2024-10-31 14:02:39 -04:00
Vitor PamplonaandGitHub 03225fc696 Merge pull request #1157 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-30 18:00:57 -04:00
Crowdin Bot a0ab4a824b New Crowdin translations by GitHub Action 2024-10-30 21:59:32 +00:00
Vitor Pamplona 0ba4ca13bf Updates compose, corektx, fragment, kotlin, runtime, and android camera libs 2024-10-30 17:56:28 -04:00
Vitor Pamplona 060a242a0f Removes logs 2024-10-30 17:55:35 -04:00
Vitor Pamplona ae530ed813 adds extra padding for the zap setup screen 2024-10-30 16:02:00 -04:00
Vitor Pamplona a5c4a53afe - Adds an Around ME feed
- Refactors location to operate as a flow
- Refactors FeedStructures to prepare for custom feeds
- Moves Account to operate feeds with location
2024-10-30 16:01:39 -04:00
Vitor Pamplona fb6137f99a Missing string 2024-10-29 12:31:07 -04:00
Vitor Pamplona de99c50639 Adds search.nos.today to bootstrapping relays for search 2024-10-29 12:23:09 -04:00
Vitor PamplonaandGitHub d5f8994128 Merge pull request #1156 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-29 12:16:33 -04:00
Crowdin Bot cf02d808d6 New Crowdin translations by GitHub Action 2024-10-29 16:10:36 +00:00
Vitor Pamplona f6aa732558 Adds DM and Search default buttons to the dialogs. 2024-10-29 12:07:15 -04:00
Vitor Pamplona d321df79e8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-28 16:11:09 -04:00
Vitor Pamplona d1b4f81083 Fixes jumpy thread view 2024-10-28 16:10:47 -04:00
Vitor PamplonaandGitHub 0205f1fd12 Merge pull request #1155 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-28 10:14:42 -04:00
Crowdin Bot d739fdac2d New Crowdin translations by GitHub Action 2024-10-28 14:11:31 +00:00
Vitor Pamplona 4a43a87a79 Moves templates to explain that bounties matter. 2024-10-28 10:08:53 -04:00
Vitor Pamplona 6f31898cdc Updates Firebase 2024-10-24 17:34:13 -04:00
Vitor Pamplona 6ebc878312 Fixes poll's background rendering with the right percentages. 2024-10-24 17:34:00 -04:00
Vitor Pamplona 518e1c88ce Adds hidden words filter to search, hashtag and geotag feeds
Applies hidden words even to hashtags that were not included in the content of the event.
Pre-process search to avoid showing and hiding posts after hidden words where processed by the UI.
2024-10-24 11:00:07 -04:00
Vitor Pamplona f88985b2bf Adds an icon to the repo 2024-10-23 18:00:23 -04:00
Vitor Pamplona 89e1e20b40 Improves algorithm for placing the clicked note in a visible position when opening a new threads that need to load dynamically 2024-10-22 17:26:53 -04:00
Vitor Pamplona c2e725ef5f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-22 17:14:36 -04:00
Vitor Pamplona 6e8c348cbc Moves NIP-44 test model classes to a new file 2024-10-22 17:14:29 -04:00
Vitor PamplonaandGitHub 84316d320d Merge pull request #1150 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-22 15:26:36 -04:00
Crowdin Bot 87fb63af06 New Crowdin translations by GitHub Action 2024-10-22 19:00:17 +00:00
Vitor Pamplona a3b0436d91 Uses NIP-78 to start syncing some data between instances of Amethyst 2024-10-22 14:52:11 -04:00
Vitor PamplonaandGitHub bb430605bc Merge pull request #1148 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-19 18:50:37 -04:00
Crowdin Bot 4c0d92f89f New Crowdin translations by GitHub Action 2024-10-19 18:19:59 +00:00
Vitor Pamplona f0b14edef6 v0.92.7 2024-10-19 14:17:42 -04:00
Vitor Pamplona aae2ffebd7 Fixes the bug of unable to decrypt mute lists. 2024-10-19 14:17:17 -04:00
Vitor Pamplona 7b11d0ce81 v0.92.6 2024-10-17 17:21:20 -04:00
Vitor Pamplona 26ba75c7c9 Fixes array too big because strfry blocks more than 20 filters 2024-10-17 17:18:35 -04:00
Vitor Pamplona 67fb3bf488 Fixes margin of poll options with translation 2024-10-17 15:22:34 -04:00
Vitor Pamplona 24b29396cb Reduces the font size for the translation label 2024-10-17 15:22:19 -04:00
Vitor Pamplona 25c3532637 Fixes poll rendering 2024-10-17 14:55:07 -04:00
Vitor Pamplona cff6ee457d Fixes zapraiser progress bar 2024-10-17 14:18:40 -04:00
Vitor Pamplona c0ba6f5b00 If the Share target intent opens with the new post screen already active, just updates the post instead of creating a new one. 2024-10-17 13:59:52 -04:00
Vitor Pamplona 44d8b8ff33 v0.92.5 2024-10-16 17:34:10 -04:00
Vitor Pamplona f495acb570 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-16 17:33:58 -04:00
Vitor PamplonaandGitHub ecfc8bcd87 Merge pull request #1144 from geovnn/main
Prevent clicks outside boost popup
2024-10-16 17:33:39 -04:00
Vitor Pamplona 724bea6bbb Updates compose, benchmark, navigation, and activity 2024-10-16 17:32:33 -04:00
Giovanni Gatti 6826db13a4 Prevent clicks outside boost popup 2024-10-16 23:11:51 +02:00
Vitor Pamplona c5dd2c5651 Fixes recreating the new post screen from intent when switching accounts. 2024-10-16 13:33:46 -04:00
Vitor Pamplona 236ff2d680 v0.92.4 2024-10-16 11:13:56 -04:00
Vitor Pamplona 13e2546d36 Makes Amethyst a share target for texts, images and videos. 2024-10-16 10:54:57 -04:00
Vitor Pamplona 549b9f53e5 Fixes new media post dialog for the edge to edge borders 2024-10-16 09:41:02 -04:00
Vitor Pamplona 974c022aed Fixes padding of the new new post screen 2024-10-15 17:20:16 -04:00
Vitor Pamplona a57566dd84 Changes the new post screen to use the non-disappearing version of the scaffold. 2024-10-15 16:55:09 -04:00
Vitor PamplonaandGitHub 72e86cf391 Merge pull request #1143 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 16:25:21 -04:00
Crowdin Bot 3b23cded56 New Crowdin translations by GitHub Action 2024-10-15 20:04:18 +00:00
Vitor Pamplona 121337eb83 Makes new post screen come in and out from the bottom 2024-10-15 16:02:20 -04:00
Vitor Pamplona fe87d642c9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt
2024-10-15 15:54:26 -04:00
Vitor Pamplona b8112ff1aa Correctly maps the write status of the outbox relays. 2024-10-15 15:53:00 -04:00
Vitor PamplonaandGitHub 1eee0bd1ae Merge pull request #1142 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 13:55:09 -04:00
Crowdin Bot f48335e3be New Crowdin translations by GitHub Action 2024-10-15 17:46:27 +00:00
Vitor PamplonaandGitHub fcc4befb22 Merge pull request #1141 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 13:44:54 -04:00
Crowdin Bot e28a1896ec New Crowdin translations by GitHub Action 2024-10-15 17:38:52 +00:00
Vitor PamplonaandGitHub f10e66e6a5 Merge pull request #1137 from geovnn/main
Change NewPostView to NewPostScreen to make it route based
2024-10-15 13:37:33 -04:00
Vitor PamplonaandGitHub 825a227e2d Merge pull request #1140 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 12:09:51 -04:00
Crowdin Bot 3446fdaf7f New Crowdin translations by GitHub Action 2024-10-15 16:08:45 +00:00
Vitor Pamplona 9c29bf3f25 v0.92.3 2024-10-15 12:06:36 -04:00
Vitor PamplonaandGitHub 9642e45775 Merge pull request #1139 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 12:06:23 -04:00
Crowdin Bot 58788d2cd5 New Crowdin translations by GitHub Action 2024-10-15 16:00:54 +00:00
Vitor Pamplona d7c18341cd Uses the same Reaction animation to also do Boosts 2024-10-15 11:58:40 -04:00
Vitor Pamplona ee16f6c143 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-15 11:28:41 -04:00
Vitor Pamplona d78b55d134 Reverts hack for fling behavior on the Vertical Pager 2024-10-15 11:28:33 -04:00
Vitor PamplonaandGitHub ff5e7359e2 Merge pull request #1138 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 11:05:39 -04:00
Crowdin Bot 4fd0f38402 New Crowdin translations by GitHub Action 2024-10-15 14:56:33 +00:00
Vitor Pamplona 5b516f4101 Updates to AGP 8.7.1 and SDK 35 2024-10-15 10:53:21 -04:00
Vitor Pamplona 1fc390c215 Do not bring the keyboard when the search field reappears. 2024-10-15 10:52:54 -04:00
Vitor Pamplona 2bb09f583b Fixes Thread view when loading a reply of a thread that is not cached yet. 2024-10-15 10:45:03 -04:00
Giovanni Gatti 89de272ea1 Change NewPostView to NewPostScreen to make it route based 2024-10-14 23:19:43 +02:00
224 changed files with 9722 additions and 4098 deletions
+3 -1
View File
@@ -30,4 +30,6 @@ If applicable, add a video and/or screenshots to help explain your problem.
- Amber Version (if using it to sign):
**Bounty (in Bitcoin sats) offered for a solution**
Incentivize developers to work on your issue. Describe clear milestones to claim payment.
The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
not even a small one, this bug will not be worked on because it doesn't matter to you. We prioritize
bug fixing to issues that have bounties, even small ones.
+3 -7
View File
@@ -7,14 +7,10 @@ assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Bounty (in Bitcoin sats) offered for the implementation**
Incentivize developers to work on your feature. Describe clear milestones to claim payment.
**Additional context**
Add any other context, video, or screenshots about the feature request here.
The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
not even a small one, this feature will not be coded because it doesn't actually matter to you.
We prioritize feature development by its bounty size compared to how much work is required to get it done.
+19 -4
View File
@@ -42,15 +42,30 @@ jobs:
- name: Build APK (gradle)
run: ./gradlew assembleDebug --no-daemon
- name: Upload APK
- name: Upload Play APK
uses: actions/upload-artifact@v4
with:
name: Debug APK
path: amethyst/build/outputs/apk/debug/app-debug.apk
name: Play Debug APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
- name: Upload FDroid APK
uses: actions/upload-artifact@v4
with:
name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
- name: Upload Compose Reports
uses: actions/upload-artifact@v4
with:
name: Compose Reports
path: amethyst/build/compose_compiler
- name: Tests
run: ./gradlew test --no-daemon
- name: Upload Test Results
uses: actions/upload-artifact@v4
with:
name: Build Reports
name: Test Reports
path: amethyst/build/reports
+4
View File
@@ -37,6 +37,10 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
+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="2.0.0" />
<option name="version" value="2.0.20" />
</component>
</project>
+4 -1
View File
@@ -20,6 +20,9 @@ Join the social network you control.
## Download and Install
[<img src="./docs/design/zapstore.svg"
alt="Get it on Zap Store"
height="70">](https://github.com/zapstore/zapstore/releases)
[<img src="./docs/design/obtainium.png"
alt="Get it on Obtaininum"
height="70">](https://github.com/ImranR98/Obtainium)
@@ -105,7 +108,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Video Events (NIP-71)
- [x] Moderated Communities (NIP-72)
- [ ] Zap Goals (NIP-75)
- [ ] Arbitrary Custom App Data (NIP-78)
- [x] Arbitrary Custom App Data (NIP-78)
- [x] Highlights (NIP-84)
- [x] Notify Request (NIP-88/Draft)
- [x] Recommended Application Handlers (NIP-89)
+5 -3
View File
@@ -16,9 +16,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 407
versionName "0.92.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"98f2acaf9b3eb70d87607942e576f9a086ac28adf37869da9a29f4815cecac86\""
versionCode 413
versionName "0.93.0"
buildConfigField "String", "RELEASE_NOTES_ID", "\"f9e228d0579b0256044b54a78037f06edfa06c24862c368ad7e02b5caa7bd309\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -230,6 +230,8 @@ dependencies {
implementation libs.coil.gif
// view svgs
implementation libs.coil.svg
// enables network for coil
implementation libs.coil.okhttp
// create blurhash
implementation libs.trbl.blurhash
@@ -26,12 +26,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.toHexKey
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import kotlinx.coroutines.CoroutineScope
@@ -47,7 +52,69 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val account =
Account(
AccountSettings(KeyPair()),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
private suspend fun getBitmap(): ByteArray {
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
}
}
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
return baos.toByteArray()
}
private suspend fun testBase(server: ServerName) {
if (server.type == ServerType.NIP96) {
testNip96(server)
} else {
testBlossom(server)
}
}
private suspend fun testBlossom(server: ServerName) {
val paylod = getBitmap()
val initialHash = CryptoUtils.sha256(paylod).toHexKey()
val inputStream = paylod.inputStream()
val result =
BlossomUploader(account)
.uploadImage(
inputStream,
initialHash,
paylod.size,
"filename.png",
"image/png",
alt = null,
sensitiveContent = null,
server,
forceProxy = { false },
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
assertEquals("image/png", result.type)
assertEquals(paylod.size.toLong(), result.size)
assertEquals(initialHash, result.sha256)
assertEquals("${server.baseUrl}/$initialHash", result.url)
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(result.url!!, false)
?: run {
fail("${server.name}: Should not be null")
return
}
val downloadedHash = CryptoUtils.sha256(imageData).toHexKey()
assertEquals(initialHash, downloadedHash)
}
private suspend fun testNip96(server: ServerName) {
val serverInfo =
Nip96Retriever()
.loadInfo(
@@ -55,28 +122,13 @@ class ImageUploadTesting {
false,
)
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
}
}
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
val inputStream = bytes.inputStream()
val account =
Account(
AccountSettings(KeyPair()),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
val paylod = getBitmap()
val inputStream = paylod.inputStream()
val result =
Nip96Uploader(account)
.uploadImage(
inputStream,
bytes.size.toLong(),
paylod.size.toLong(),
"image/png",
alt = null,
sensitiveContent = null,
@@ -140,7 +192,7 @@ class ImageUploadTesting {
@Test
fun runTestOnDefaultServers() =
runBlocking {
Nip96MediaServers.DEFAULT.forEach {
DEFAULT_MEDIA_SERVERS.forEach {
testBase(it)
}
}
@@ -148,58 +200,76 @@ class ImageUploadTesting {
@Test()
fun testNostrCheck() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
testBase(ServerName("nostrcheck.me", "https://nostrcheck.me", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrage() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testSove() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
}
@Test()
fun testNostrBuild() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
testBase(ServerName("nostr.build", "https://nostr.build", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testSovbit() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
}
@Test()
fun testVoidCat() =
runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96))
}
@Test()
fun testNostrPic() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
testBase(ServerName("nostpic.com", "https://nostpic.com", ServerType.NIP96))
}
@Test(expected = RuntimeException::class)
fun testSprovoostNl() =
runBlocking {
testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/"))
testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrOnch() =
runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services"))
testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
}
@Ignore("Changes sha256")
fun testPrimalBlossom() =
runBlocking {
testBase(ServerName("primal.net", "https://blossom.primal.net", ServerType.Blossom))
}
@Test()
fun testNostrCheckBlossom() =
runBlocking {
testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom))
}
@Ignore("Requires Payment")
fun testSatelliteBlossom() =
runBlocking {
testBase(ServerName("satellite", "https://cdn.satellite.earth", ServerType.Blossom))
}
}
@@ -27,6 +27,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CancellationException
@RunWith(AndroidJUnit4::class)
class TranslationsTest {
@@ -124,7 +125,10 @@ class TranslationsTest {
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
"pt",
)
}
@Test(expected = CancellationException::class)
fun testTranslationLnInvoice2() {
assertTranslateContains(
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
@@ -132,7 +136,7 @@ class TranslationsTest {
)
}
@Test
@Test(expected = CancellationException::class)
fun testNostrEvents() {
assertTranslateContains(
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
@@ -162,4 +166,15 @@ class TranslationsTest {
"pt",
)
}
@Test
fun testHttp() {
val text = "https://m.primal.net/MdDd.png \nRunning... \uD83D\uDE01 nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm"
assertTranslateContains(
"https://m.primal.net/MdDd.png",
text,
"pt",
)
}
}
+18 -8
View File
@@ -19,12 +19,8 @@
<!-- To take pictures -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- To Upload media (newer devices) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<!-- To Upload media (old devices) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- To read QRs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.CAMERA" />
@@ -103,9 +99,23 @@
<data android:scheme="nostr+walletconnect" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
@@ -29,9 +29,12 @@ import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.request.crossfade
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.playback.VideoCache
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CoroutineScope
@@ -42,6 +45,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okio.Path.Companion.toOkioPath
import java.io.File
import kotlin.time.measureTimedValue
@@ -50,6 +54,7 @@ class Amethyst : Application() {
// Service Manager is only active when the activity is active.
val serviceManager = ServiceManager(applicationIOScope)
val locationManager = LocationState(this, applicationIOScope)
override fun onTerminate() {
super.onTerminate()
@@ -72,15 +77,16 @@ class Amethyst : Application() {
val coilCache: DiskCache by lazy {
DiskCache
.Builder()
.directory(safeCacheDir.resolve("image_cache"))
.directory(safeCacheDir.resolve("image_cache").toOkioPath())
.maxSizePercent(0.2)
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
.build()
}
val coilMemCache: MemoryCache by lazy {
val memoryCache: MemoryCache by lazy {
MemoryCache
.Builder(this)
.Builder()
.maxSizePercent(this)
.build()
}
@@ -126,7 +132,7 @@ class Amethyst : Application() {
ImageLoader
.Builder(this)
.diskCache { coilCache }
.memoryCache { coilMemCache }
.memoryCache { memoryCache }
.crossfade(true)
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
@@ -140,7 +146,10 @@ class Amethyst : Application() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() }
GlobalScope.launch(Dispatchers.Default) {
println("Trim Memory Inside $level")
serviceManager.trimMemory()
}
}
companion object {
@@ -26,7 +26,6 @@ import android.content.pm.ApplicationInfo
import android.os.Debug
import android.util.Log
import androidx.core.content.getSystemService
import coil.Coil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
@@ -92,14 +91,13 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
Log.d(
"STATE DUMP",
"Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB",
"Image Disk Cache ${(Amethyst.instance.coilCache.size) / (1024 * 1024)}/${(Amethyst.instance.coilCache.maxSize) / (1024 * 1024)} MB",
)
Log.d(
"STATE DUMP",
"Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB",
"Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.size) / (1024 * 1024)} MB",
)
Log.d(
@@ -26,14 +26,20 @@ import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.AccountLanguagePreferencesInternal
import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal
import com.vitorpamplona.amethyst.model.AccountSecurityPreferencesInternal
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
import com.vitorpamplona.amethyst.model.AccountZapPreferencesInternal
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
@@ -45,6 +51,7 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.Event
@@ -53,11 +60,9 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -104,6 +109,7 @@ private object PrefKeys {
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
const val LATEST_MUTE_LIST = "latestMuteList"
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
@@ -310,19 +316,7 @@ object LocalPreferences {
}
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom)
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
putString(
PrefKeys.LANGUAGE_PREFS,
Event.mapper.writeValueAsString(settings.languagePreferences),
)
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
putString(
PrefKeys.REACTION_CHOICES,
Event.mapper.writeValueAsString(settings.reactionChoices.value),
)
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
putString(
PrefKeys.DEFAULT_FILE_SERVER,
Event.mapper.writeValueAsString(settings.defaultFileServer),
@@ -404,6 +398,15 @@ object LocalPreferences {
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
}
if (settings.backupAppSpecificData != null) {
putString(
PrefKeys.LATEST_APP_SPECIFIC_DATA,
Event.mapper.writeValueAsString(settings.backupAppSpecificData),
)
} else {
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
@@ -414,9 +417,6 @@ object LocalPreferences {
putString(PrefKeys.TOR_SETTINGS, Event.mapper.writeValueAsString(settings.torSettings.toSettings()))
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers)
val regularMap =
settings.lastReadPerRoute.value.mapValues {
it.value.value
@@ -428,12 +428,6 @@ object LocalPreferences {
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
if (settings.showSensitiveContent.value == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!)
}
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
@@ -441,6 +435,7 @@ object LocalPreferences {
}.apply()
}
}
Log.d("LocalPreferences", "Saved to encrypted storage")
}
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
@@ -510,9 +505,6 @@ object LocalPreferences {
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList =
@@ -528,13 +520,12 @@ object LocalPreferences {
} ?: LnZapEvent.ZapType.PUBLIC
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val defaultFileServer = parseOrNull<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0]
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
@@ -543,6 +534,54 @@ object LocalPreferences {
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
val syncedSettings =
if (latestAppSpecificData != null) {
null
} else {
// previous version. Delete this when ready.
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
AccountSyncedSettingsInternal(
reactions =
AccountReactionPreferencesInternal(
reactionChoices = reactionChoices,
),
zaps =
AccountZapPreferencesInternal(
zapAmountChoices = zapAmountChoices,
defaultZapType = defaultZapType,
),
languages =
AccountLanguagePreferencesInternal(
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
),
security =
AccountSecurityPreferencesInternal(
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
),
)
}
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
@@ -571,15 +610,6 @@ object LocalPreferences {
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
}
val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
MutableStateFlow(it.value)
@@ -594,12 +624,6 @@ object LocalPreferences {
externalSignerPackageName = externalSignerPackageName,
localRelays = localRelays,
localRelayServers = localRelayServers,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
zapAmountChoices = MutableStateFlow(zapAmountChoices.toImmutableList()),
reactionChoices = MutableStateFlow(reactionChoices.toImmutableList()),
defaultZapType = MutableStateFlow(defaultZapType),
defaultFileServer = defaultFileServer,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
@@ -616,10 +640,9 @@ object LocalPreferences {
backupSearchRelayList = latestSearchRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
backupMuteList = latestMuteList,
backupAppSpecificData = latestAppSpecificData,
backupSyncedSettings = syncedSettings,
torSettings = TorSettingsFlow.build(torSettings),
showSensitiveContent = MutableStateFlow(showSensitiveContent),
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
pendingAttestations = MutableStateFlow(pendingAttestations),
@@ -0,0 +1,92 @@
/**
* 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 kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
/**
* Launches an async coroutine for each item, runs the
* function and waits for everybody to finsih
*/
suspend fun <T> launchAndWaitAll(
items: List<T>,
asyncFunc: suspend (T) -> Unit,
) {
coroutineScope {
val jobs =
items.map { next ->
async {
asyncFunc(next)
}
}
// runs in parallel to avoid overcrowding Amber.
withTimeoutOrNull(15000) {
jobs.joinAll()
}
}
}
/**
* Runs the function and waits for 10 seconds for any result.
*/
suspend inline fun <T> tryAndWait(crossinline asyncFunc: (CancellableContinuation<T>) -> Unit): T? =
withTimeoutOrNull(10000) {
suspendCancellableCoroutine { continuation ->
asyncFunc(continuation)
}
}
/**
* Runs an async coroutine for each one of the items,
* runs the request for that item,
* and gathers all the results in the output map.
*/
suspend fun <T, K> collectSuccessfulOperations(
items: List<T>,
runRequestFor: (T, (K) -> Unit) -> Unit,
output: MutableMap<T, K> = mutableMapOf(),
onReady: suspend (MutableMap<T, K>) -> Unit,
) {
if (items.isEmpty()) {
onReady(output)
return
}
launchAndWaitAll(items) {
val result =
tryAndWait { continuation ->
runRequestFor(it) { result: K -> continuation.resume(result) }
}
if (result != null) {
output[it] = result
}
}
onReady(output)
}
@@ -23,15 +23,17 @@ package com.vitorpamplona.amethyst
import android.os.Build
import android.util.Log
import androidx.compose.runtime.Stable
import coil.Coil
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.size.Precision
import coil.util.DebugLogger
import coil3.SingletonImageLoader
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.size.Precision
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Base64Fetcher
import com.vitorpamplona.amethyst.service.BlurHashFetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -115,27 +117,40 @@ class ServiceManager(
HttpClientManager.setDefaultProxy(null)
}
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
// Convert this into a flow
LocalCache.antiSpam.active = account
?.settings
?.syncedSettings
?.security
?.filterSpamFromStrangers ?: true
SingletonImageLoader.setSafe {
Amethyst.instance
.imageLoaderBuilder()
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(
OkHttpNetworkFetcherFactory(
callFactory = {
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
?: HttpClientManager.getHttpClient(false)
},
),
)
}.apply {
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
this.logger(DebugLogger())
}
}.okHttpClient {
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
?: HttpClientManager.getHttpClient(false)
}.precision(Precision.INEXACT)
.respectCacheHeaders(false)
.build()
}
File diff suppressed because it is too large Load Diff
@@ -20,10 +20,11 @@
*/
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.ammolite.relays.Constants
@@ -34,6 +35,7 @@ import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -44,8 +46,6 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -61,17 +61,6 @@ val DefaultChannels =
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
)
val DefaultReactions =
persistentListOf(
"\uD83D\uDE80",
"\uD83E\uDEC2",
"\uD83D\uDC40",
"\uD83D\uDE02",
"\uD83C\uDF89",
"\uD83E\uDD14",
"\uD83D\uDE31",
)
val DefaultNIP65List =
listOf(
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
@@ -81,9 +70,9 @@ val DefaultNIP65List =
val DefaultDMRelayList =
listOf(
RelayUrlFormatter.normalize("wss://auth.nostr1.com/"),
RelayUrlFormatter.normalize("wss://nostr.mom/"),
RelayUrlFormatter.normalize("wss://nos.lol/"),
RelayUrlFormatter.normalize("wss://auth.nostr1.com"),
RelayUrlFormatter.normalize("wss://relay.0xchat.com"),
RelayUrlFormatter.normalize("wss://nos.lol"),
)
val DefaultSearchRelayList =
@@ -91,25 +80,18 @@ val DefaultSearchRelayList =
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
RelayUrlFormatter.normalize("wss://nostr.wine"),
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
RelayUrlFormatter.normalize("wss://search.nos.today"),
)
val DefaultZapAmounts = persistentListOf(100L, 500L, 1000L)
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { codedList.add(it.language) }
}
return codedList
}
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val GLOBAL_FOLLOWS = " Global "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " All Follows "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val AROUND_ME = " Around Me "
@Stable
class AccountSettings(
val keyPair: KeyPair,
@@ -117,13 +99,7 @@ class AccountSettings(
var externalSignerPackageName: String? = null,
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var localRelayServers: Set<String> = setOf(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>> = MutableStateFlow(DefaultZapAmounts),
var reactionChoices: MutableStateFlow<ImmutableList<String>> = MutableStateFlow(DefaultReactions),
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType> = MutableStateFlow(LnZapEvent.ZapType.PUBLIC),
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
@@ -139,15 +115,17 @@ class AccountSettings(
var backupSearchRelayList: SearchRelayListEvent? = null,
var backupMuteList: MuteListEvent? = null,
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
var backupAppSpecificData: AppSpecificDataEvent? = null,
backupSyncedSettings: AccountSyncedSettingsInternal? = null, // only exist for migration purposes
val torSettings: TorSettingsFlow = TorSettingsFlow(),
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
) {
val saveable = MutableStateFlow(AccountSettingsUpdater(this))
val syncedSettings: AccountSyncedSettings =
backupSyncedSettings?.let { AccountSyncedSettings(it) }
?: AccountSyncedSettings(AccountSyncedSettingsInternal())
class AccountSettingsUpdater(
val accountSettings: AccountSettings,
@@ -165,7 +143,19 @@ class AccountSettings(
} else {
when (val packageName = externalSignerPackageName) {
null -> NostrSignerInternal(keyPair)
else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName))
else -> {
val externalSignerLauncher = ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName)
// TODO: How to handle the launcher here?
try {
externalSignerLauncher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
} catch (e: Exception) {
Log.d("AccountSettings", "Failed to initialize external signer", e)
}
NostrSignerExternal(keyPair.pubKey.toHexKey(), externalSignerLauncher)
}
}
}
@@ -173,39 +163,47 @@ class AccountSettings(
// Zaps and Reactions
// ---
fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
if (defaultZapType.value != zapType) {
defaultZapType.tryEmit(zapType)
fun changeDefaultZapType(zapType: LnZapEvent.ZapType): Boolean {
if (syncedSettings.zaps.defaultZapType.value != zapType) {
syncedSettings.zaps.defaultZapType.tryEmit(zapType)
saveAccountSettings()
return true
}
return false
}
fun changeZapAmounts(newAmounts: List<Long>) {
if (zapAmountChoices.value != newAmounts) {
zapAmountChoices.tryEmit(newAmounts.toImmutableList())
fun changeZapAmounts(newAmounts: List<Long>): Boolean {
if (syncedSettings.zaps.zapAmountChoices.value != newAmounts) {
syncedSettings.zaps.zapAmountChoices.tryEmit(newAmounts.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
fun changeReactionTypes(newTypes: List<String>): Boolean {
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?): Boolean {
if (zapPaymentRequest != newServer) {
zapPaymentRequest = newServer
saveAccountSettings()
return true
}
}
fun changeReactionTypes(newTypes: List<String>) {
if (reactionChoices.value != newTypes) {
reactionChoices.tryEmit(newTypes.toImmutableList())
saveAccountSettings()
}
return false
}
// ---
// file servers
// ---
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
fun changeDefaultFileServer(server: ServerName) {
if (defaultFileServer != server) {
defaultFileServer = server
saveAccountSettings()
@@ -260,22 +258,18 @@ class AccountSettings(
// language services
// ---
fun toggleDontTranslateFrom(languageCode: String) {
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
saveAccountSettings()
} else {
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
saveAccountSettings()
}
syncedSettings.languages.toggleDontTranslateFrom(languageCode)
saveAccountSettings()
}
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
fun translateToContains(languageCode: Locale) = syncedSettings.languages.translateTo.contains(languageCode.language)
fun updateTranslateTo(languageCode: Locale) {
if (translateTo != languageCode.language) {
translateTo = languageCode.language
fun updateTranslateTo(languageCode: Locale): Boolean {
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
saveAccountSettings()
return true
}
return false
}
fun prefer(
@@ -283,23 +277,14 @@ class AccountSettings(
target: String,
preference: String,
) {
val key = "$source,$target"
if (key !in languagePreferences) {
languagePreferences = languagePreferences + Pair(key, preference)
saveAccountSettings()
} else {
if (languagePreferences.get(key) == preference) {
languagePreferences = languagePreferences.minus(key)
} else {
languagePreferences = languagePreferences + Pair(key, preference)
}
}
syncedSettings.languages.prefer(source, target, preference)
saveAccountSettings()
}
fun preferenceBetween(
source: String,
target: String,
): String? = languagePreferences["$source,$target"]
): String? = syncedSettings.languages.preferenceBetween(source, target)
// ----
// Backup Lists
@@ -382,6 +367,21 @@ class AccountSettings(
}
}
fun updateAppSpecificData(
appSettings: AppSpecificDataEvent?,
newSyncedSettings: AccountSyncedSettingsInternal,
) {
if (appSettings == null || appSettings.content().isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupAppSpecificData?.id != appSettings.id) {
backupAppSpecificData = appSettings
syncedSettings.updateFrom(newSyncedSettings)
saveAccountSettings()
}
}
// ----
// Warning dialogs
// ----
@@ -407,15 +407,6 @@ class AccountSettings(
}
}
fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (showSensitiveContent.value != show) {
showSensitiveContent.update { show }
saveAccountSettings()
return true
}
return false
}
// ---
// donations
// ---
@@ -509,16 +500,20 @@ class AccountSettings(
// ---
// filters
// ---
fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (syncedSettings.security.updateShowSensitiveContent(show)) {
saveAccountSettings()
return true
}
return false
}
fun updateOptOutOptions(
warnReports: Boolean,
filterSpam: Boolean,
): Boolean =
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
warnAboutPostsWithReports = warnReports
filterSpamFromStrangers = filterSpam
if (syncedSettings.security.updateOptOutOptions(warnReports, filterSpam)) {
saveAccountSettings()
true
} else {
false
@@ -0,0 +1,208 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.events.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.util.Locale
@Stable
class AccountSyncedSettings(
internalSettings: AccountSyncedSettingsInternal,
) {
val reactions = AccountReactionPreferences(MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()))
val zaps =
AccountZapPreferences(
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
MutableStateFlow(internalSettings.zaps.defaultZapType),
)
val languages =
AccountLanguagePreferences(
internalSettings.languages.dontTranslateFrom,
internalSettings.languages.languagePreferences,
internalSettings.languages.translateTo,
)
val security =
AccountSecurityPreferences(
MutableStateFlow(internalSettings.security.showSensitiveContent),
internalSettings.security.warnAboutPostsWithReports,
internalSettings.security.filterSpamFromStrangers,
)
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value),
zaps =
AccountZapPreferencesInternal(
zaps.zapAmountChoices.value,
zaps.defaultZapType.value,
),
languages =
AccountLanguagePreferencesInternal(
languages.dontTranslateFrom,
languages.languagePreferences,
languages.translateTo,
),
security =
AccountSecurityPreferencesInternal(
security.showSensitiveContent.value,
security.warnAboutPostsWithReports,
security.filterSpamFromStrangers,
),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
val newReactionChoices = syncedSettingsInternal.reactions.reactionChoices.toImmutableList()
if (!equalImmutableLists(reactions.reactionChoices.value, newReactionChoices)) {
reactions.reactionChoices.tryEmit(newReactionChoices)
}
val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList()
if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) {
zaps.zapAmountChoices.tryEmit(newZapChoices)
}
if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) {
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
}
if (languages.dontTranslateFrom != syncedSettingsInternal.languages.dontTranslateFrom) {
languages.dontTranslateFrom = syncedSettingsInternal.languages.dontTranslateFrom
}
if (languages.languagePreferences != syncedSettingsInternal.languages.languagePreferences) {
languages.languagePreferences = syncedSettingsInternal.languages.languagePreferences
}
if (languages.translateTo != syncedSettingsInternal.languages.translateTo) {
languages.translateTo = syncedSettingsInternal.languages.translateTo
}
if (security.showSensitiveContent.value != syncedSettingsInternal.security.showSensitiveContent) {
security.showSensitiveContent.tryEmit(syncedSettingsInternal.security.showSensitiveContent)
}
if (security.filterSpamFromStrangers != syncedSettingsInternal.security.filterSpamFromStrangers) {
security.filterSpamFromStrangers = syncedSettingsInternal.security.filterSpamFromStrangers
}
if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) {
security.warnAboutPostsWithReports = syncedSettingsInternal.security.warnAboutPostsWithReports
}
}
}
@Stable
class AccountReactionPreferences(
var reactionChoices: MutableStateFlow<ImmutableList<String>>,
)
@Stable
class AccountZapPreferences(
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
)
@Stable
class AccountLanguagePreferences(
var dontTranslateFrom: Set<String>,
var languagePreferences: Map<String, String>,
var translateTo: String,
) {
// ---
// language services
// ---
fun toggleDontTranslateFrom(languageCode: String) {
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
} else {
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
}
}
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
fun updateTranslateTo(languageCode: Locale): Boolean {
if (translateTo != languageCode.language) {
translateTo = languageCode.language
return true
}
return false
}
fun prefer(
source: String,
target: String,
preference: String,
) {
val key = "$source,$target"
if (key !in languagePreferences) {
languagePreferences = languagePreferences + Pair(key, preference)
} else {
if (languagePreferences.get(key) == preference) {
languagePreferences = languagePreferences.minus(key)
} else {
languagePreferences = languagePreferences + Pair(key, preference)
}
}
}
fun preferenceBetween(
source: String,
target: String,
): String? = languagePreferences["$source,$target"]
}
@Stable
class AccountSecurityPreferences(
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
) {
fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (showSensitiveContent.value != show) {
showSensitiveContent.update { show }
return true
}
return false
}
// ---
// filters
// ---
fun updateOptOutOptions(
warnReports: Boolean,
filterSpam: Boolean,
): Boolean =
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
warnAboutPostsWithReports = warnReports
filterSpamFromStrangers = filterSpam
true
} else {
false
}
}
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.quartz.events.LnZapEvent
import java.util.Locale
val DefaultReactions =
listOf(
"\uD83D\uDE80",
"\uD83E\uDEC2",
"\uD83D\uDC40",
"\uD83D\uDE02",
"\uD83C\uDF89",
"\uD83E\uDD14",
"\uD83D\uDE31",
)
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { codedList.add(it.language) }
}
return codedList
}
class AccountSyncedSettingsInternal(
val reactions: AccountReactionPreferencesInternal = AccountReactionPreferencesInternal(),
val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(),
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
)
class AccountReactionPreferencesInternal(
var reactionChoices: List<String> = DefaultReactions,
)
class AccountZapPreferencesInternal(
var zapAmountChoices: List<Long> = DefaultZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
)
class AccountLanguagePreferencesInternal(
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
)
class AccountSecurityPreferencesInternal(
val showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
)
@@ -31,7 +31,7 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.encoders.toNEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
@@ -107,7 +107,7 @@ abstract class Channel(
open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idNote() = id().toNEvent()
open fun idDisplayNote() = idNote().toShortenHex()
@@ -40,12 +40,15 @@ import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.AppRecommendationEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BaseAddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.BlossomServersEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarEvent
@@ -61,6 +64,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityListEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
@@ -82,6 +86,9 @@ import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.GitRepositoryEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -99,6 +106,7 @@ import com.vitorpamplona.quartz.events.NIP90UserDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NNSEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PictureEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
@@ -437,61 +445,30 @@ object LocalCache {
fun consume(
event: TextNoteEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
return
}
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
) = consumeRegularEvent(event, relay)
fun consume(
event: TorrentEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
) = consumeRegularEvent(event, relay)
fun consume(
event: TorrentCommentEvent,
event: InteractiveStoryPrologueEvent,
relay: Relay?,
) = consumeBaseReplaceable(event, relay)
fun consume(
event: InteractiveStorySceneEvent,
relay: Relay?,
) = consumeBaseReplaceable(event, relay)
fun consume(
event: InteractiveStoryReadingStateEvent,
relay: Relay?,
) = consumeBaseReplaceable(event, relay)
fun consumeRegularEvent(
event: Event,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
@@ -505,242 +482,69 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
val replyTo = computeReplyTo(event)
if (event is BaseTextNoteEvent && antiSpam.isSpam(event, relay)) {
return
}
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: PictureEvent,
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: TorrentCommentEvent,
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90ContentDiscoveryResponseEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Log.d("TN", "New Response ${event.taggedEvents().joinToString(", ") { it }}}")
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90ContentDiscoveryRequestEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90StatusEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90UserDiscoveryResponseEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90UserDiscoveryRequestEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: GitPatchEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: GitIssueEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: GitReplyEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
return
}
val replyTo = computeReplyTo(event)
// println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}")
note.loadEvent(event, author, replyTo)
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: LongTextNoteEvent,
@@ -817,6 +621,8 @@ object LocalCache {
is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is GitReplyEvent -> event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is LnZapEvent ->
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
@@ -865,35 +671,8 @@ object LocalCache {
fun consume(
event: PollNoteEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
return
}
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
relay: Relay?,
) = consumeRegularEvent(event, relay)
private fun consume(
event: LiveActivitiesEvent,
@@ -953,6 +732,13 @@ object LocalCache {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: BlossomServersEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: FileServersEvent,
relay: Relay?,
@@ -1133,25 +919,10 @@ object LocalCache {
}
}
fun consume(event: BadgeAwardEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val awardDefinition = computeReplyTo(event)
note.loadEvent(event, author, awardDefinition)
// Replies of an Badge Definition are Award Events
awardDefinition.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: BadgeAwardEvent,
relay: Relay?,
) = consumeRegularEvent(event, relay)
private fun comsume(
event: NNSEvent,
@@ -1232,6 +1003,13 @@ object LocalCache {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: AppSpecificDataEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
// // Log.d("RR", event.toJson())
@@ -1624,6 +1402,11 @@ object LocalCache {
refreshObservers(note)
}
fun consume(
event: CommentEvent,
relay: Relay?,
) = consumeRegularEvent(event, relay)
fun consume(
event: LiveActivitiesChatMessageEvent,
relay: Relay?,
@@ -1719,102 +1502,27 @@ object LocalCache {
fun consume(
event: AudioHeaderEvent,
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)
}
) = consumeRegularEvent(event, relay)
fun consume(
event: FileHeaderEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
) = consumeRegularEvent(event, relay)
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)
}
) = consumeRegularEvent(event, relay)
fun consume(
event: FileStorageHeaderEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
) = consumeRegularEvent(event, relay)
fun consume(
event: FhirResourceEvent,
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)
}
) = consumeRegularEvent(event, relay)
fun consume(
event: TextNoteModificationEvent,
@@ -1847,22 +1555,7 @@ object LocalCache {
fun consume(
event: HighlightEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
) = consumeRegularEvent(event, relay)
fun consume(
event: FileStorageEvent,
@@ -2035,7 +1728,10 @@ object LocalCache {
}
}
fun findUsersStartingWith(username: String): List<User> {
fun findUsersStartingWith(
username: String,
forAccount: Account?,
): List<User> {
checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username)
@@ -2048,13 +1744,19 @@ object LocalCache {
}
return users.filter { _, user: User ->
(user.anyNameStartsWith(username)) ||
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
(
(user.anyNameStartsWith(username)) ||
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
) &&
(forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.flowHiddenUsers.value.hiddenWordsCase)))
}
}
fun findNotesStartingWith(text: String): List<Note> {
fun findNotesStartingWith(
text: String,
forAccount: Account,
): List<Note> {
checkNotInMainThread()
val key = decodeEventIdAsHexOrNull(text)
@@ -2078,14 +1780,21 @@ object LocalCache {
}
if (note.event?.matchTag1With(text) == true ||
note.idHex.startsWith(text, true) ||
note.idNote().startsWith(text, true)
note.idHex.startsWith(text, true)
) {
return@filter true
if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) {
return@filter true
} else {
return@filter false
}
}
if (note.event?.isContentEncoded() == false) {
return@filter note.event?.content()?.contains(text, true) ?: false
if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) {
return@filter note.event?.content()?.contains(text, true) ?: false
} else {
return@filter false
}
}
return@filter false
@@ -2104,11 +1813,19 @@ object LocalCache {
if (addressable.event?.matchTag1With(text) == true ||
addressable.idHex.startsWith(text, true)
) {
return@filter true
if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) {
return@filter true
} else {
return@filter false
}
}
if (addressable.event?.isContentEncoded() == false) {
return@filter addressable.event?.content()?.contains(text, true) ?: false
if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) {
return@filter addressable.event?.content()?.contains(text, true) ?: false
} else {
return@filter false
}
}
return@filter false
@@ -2645,11 +2362,13 @@ object LocalCache {
is AdvertisedRelayListEvent -> consume(event, relay)
is AppDefinitionEvent -> consume(event, relay)
is AppRecommendationEvent -> consume(event, relay)
is AppSpecificDataEvent -> consume(event, relay)
is AudioHeaderEvent -> consume(event, relay)
is AudioTrackEvent -> consume(event, relay)
is BadgeAwardEvent -> consume(event)
is BadgeAwardEvent -> consume(event, relay)
is BadgeDefinitionEvent -> consume(event, relay)
is BadgeProfilesEvent -> consume(event)
is BlossomServersEvent -> consume(event, relay)
is BookmarkListEvent -> consume(event)
is CalendarEvent -> consume(event, relay)
is CalendarDateSlotEvent -> consume(event, relay)
@@ -2664,6 +2383,7 @@ object LocalCache {
is ChatMessageEvent -> consume(event, relay)
is ChatMessageRelayListEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event, relay)
is CommentEvent -> consume(event, relay)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityListEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
@@ -2691,6 +2411,9 @@ object LocalCache {
is GitPatchEvent -> consume(event, relay)
is GitRepositoryEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is InteractiveStoryPrologueEvent -> consume(event, relay)
is InteractiveStorySceneEvent -> consume(event, relay)
is InteractiveStoryReadingStateEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)
is LnZapEvent -> {
@@ -2713,6 +2436,7 @@ object LocalCache {
is MuteListEvent -> consume(event, relay)
is NNSEvent -> comsume(event, relay)
is OtsEvent -> consume(event, relay)
is PictureEvent -> consume(event, relay)
is PrivateDmEvent -> consume(event, relay)
is PrivateOutboxRelayListEvent -> consume(event, relay)
is PinListEvent -> consume(event, relay)
@@ -25,9 +25,11 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.relays.updated
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -40,7 +42,6 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
@@ -65,13 +66,12 @@ import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import kotlin.coroutines.resume
@@ -79,9 +79,9 @@ import kotlin.coroutines.resume
class AddressableNote(
val address: ATag,
) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun idNote() = address.toNAddr(relayHintUrl())
override fun toNEvent() = address.toNAddr()
override fun toNEvent() = address.toNAddr(relayHintUrl())
override fun idDisplayNote() = idNote().toShortenHex()
@@ -96,7 +96,7 @@ class AddressableNote(
return minOf(publishedAt, lastCreatedAt)
}
fun dTag(): String? = (event as? AddressableEvent)?.dTag()
fun dTag(): String = address.dTag
override fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
@@ -145,7 +145,7 @@ open class Note(
fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idNote() = toNEvent()
open fun toNEvent(): String {
val myEvent = event
@@ -156,13 +156,27 @@ open class Note(
host.id,
host.pubKey,
host.kind,
relays.firstOrNull()?.url,
relayHintUrl(),
)
} else {
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl())
}
} else {
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl())
}
}
fun relayHintUrl(): String? {
val authorRelay = author?.latestMetadataRelay
return if (relays.isNotEmpty()) {
if (authorRelay != null && relays.any { it.url == authorRelay }) {
authorRelay
} else {
relays.firstOrNull()?.url
}
} else {
null
}
}
@@ -423,31 +437,36 @@ open class Note(
}
}
private fun recursiveIsPaidByCalculation(
private suspend fun isPaidByCalculation(
account: Account,
remainingZapPayments: List<Pair<Note, Note?>>,
zapEvents: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapPayments.isEmpty()) {
if (zapEvents.isEmpty()) {
return
}
val next = remainingZapPayments.first()
var hasSentOne = false
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
if (
response is PayInvoiceSuccessResponse &&
account.isNIP47Author(zapResponseEvent.requestAuthor())
) {
launchAndWaitAll(zapEvents) { next ->
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
val result =
tryAndWait { continuation ->
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
if (
response is PayInvoiceSuccessResponse &&
account.isNIP47Author(zapResponseEvent.requestAuthor())
) {
continuation.resume(true)
}
}
}
if (!hasSentOne && result == true) {
hasSentOne = true
onWasZappedByAuthor()
} else {
recursiveIsPaidByCalculation(
account,
remainingZapPayments.minus(next),
onWasZappedByAuthor,
)
}
}
}
@@ -457,14 +476,14 @@ open class Note(
option: Int?,
user: User,
account: Account,
remainingZapEvents: Map<Note, Note?>,
zapEvents: Map<Note, Note?>,
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapEvents.isEmpty()) {
if (zapEvents.isEmpty()) {
return
}
remainingZapEvents.forEach { next ->
zapEvents.forEach { next ->
val zapRequest = next.key.event as LnZapRequestEvent
val zapEvent = next.value?.event as? LnZapEvent
@@ -487,11 +506,9 @@ open class Note(
} else {
if (account.isWriteable()) {
val result =
withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
zapRequest.decryptPrivateZap(account.signer) {
continuation.resume(it)
}
tryAndWait { continuation ->
zapRequest.decryptPrivateZap(account.signer) {
continuation.resume(it)
}
}
@@ -512,7 +529,7 @@ open class Note(
) {
isZappedByCalculation(null, user, account, zaps, onWasZappedByAuthor)
if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
isPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
}
}
@@ -564,23 +581,79 @@ open class Note(
zapsAmount = sumOfAmounts
}
private fun recursiveZappedAmountCalculation(
invoiceSet: LinkedHashSet<String>,
remainingZapPayments: List<Pair<Note, Note?>>,
private suspend fun zappedAmountCalculation(
startAmount: BigDecimal,
paidInvoiceSet: LinkedHashSet<String>,
zapPayments: List<Pair<Note, Note?>>,
signer: NostrSigner,
output: BigDecimal,
onReady: (BigDecimal) -> Unit,
) {
if (remainingZapPayments.isEmpty()) {
onReady(output)
if (zapPayments.isEmpty()) {
onReady(startAmount)
return
}
val next = remainingZapPayments.first()
var output: BigDecimal = startAmount
(next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent ->
launchAndWaitAll(zapPayments) { next ->
val result =
tryAndWait { continuation ->
processZapAmountFromResponse(
next.first,
next.second,
continuation,
signer,
)
}
if (result != null && !paidInvoiceSet.contains(result.invoice)) {
paidInvoiceSet.add(result.invoice)
output = output.add(result.amount)
}
}
onReady(output)
}
private fun processZapAmountFromResponse(
paymentRequest: Note,
paymentResponse: Note?,
continuation: CancellableContinuation<InvoiceAmount?>,
signer: NostrSigner,
) {
val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent
val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent
if (nwcRequest != null && nwcResponse != null) {
processZapAmountFromResponse(
nwcRequest,
nwcResponse,
continuation,
signer,
)
} else {
continuation.resume(null)
}
}
class InvoiceAmount(
val invoice: String,
val amount: BigDecimal,
)
private fun processZapAmountFromResponse(
nwcRequest: LnZapPaymentRequestEvent,
nwcResponse: LnZapPaymentResponseEvent,
continuation: CancellableContinuation<InvoiceAmount?>,
signer: NostrSigner,
) {
// if we can decrypt the reply
nwcResponse.response(signer) { noteEvent ->
// if it is a sucess
if (noteEvent is PayInvoiceSuccessResponse) {
(next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice ->
// if we can decrypt the invoice
nwcRequest.lnInvoice(signer) { invoice ->
// if we can parse the amount
val amount =
try {
LnInvoiceUtil.getAmountInSats(invoice)
@@ -589,26 +662,20 @@ open class Note(
null
}
var newAmount = output
if (amount != null && !invoiceSet.contains(invoice)) {
invoiceSet.add(invoice)
newAmount += amount
// avoid double counting
if (amount != null) {
continuation.resume(InvoiceAmount(invoice, amount))
} else {
continuation.resume(null)
}
recursiveZappedAmountCalculation(
invoiceSet,
remainingZapPayments.minus(next),
signer,
newAmount,
onReady,
)
}
} else {
continuation.resume(null)
}
}
}
fun zappedAmountWithNWCPayments(
suspend fun zappedAmountWithNWCPayments(
signer: NostrSigner,
onReady: (BigDecimal) -> Unit,
) {
@@ -619,11 +686,11 @@ open class Note(
val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } }
recursiveZappedAmountCalculation(
zappedAmountCalculation(
zapsAmount,
invoiceSet,
zapPayments.toList(),
signer,
zapsAmount,
onReady,
)
}
@@ -771,29 +838,11 @@ open class Note(
return true
}
if (author?.toBestDisplayName()?.containsAny(accountChoices.hiddenWordsCase) == true) {
if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) {
return true
}
if (author?.profilePicture()?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.banner?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.about?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.lud06?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.info?.lud16?.containsAny(accountChoices.hiddenWordsCase) == true) {
return true
}
if (author?.containsAny(accountChoices.hiddenWordsCase) == true) return true
}
return false
@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
class ThreadAssembler {
private fun searchRoot(
@@ -71,32 +73,50 @@ class ThreadAssembler {
return null
}
fun findThreadFor(noteId: String): Set<Note> {
@Stable
class ThreadInfo(
val root: Note,
val allNotes: ImmutableSet<Note>,
)
fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread()
val (result, elapsed) =
measureTimedValue {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null
if (note.event != null) {
val thread = OnlyLatestVersionSet()
return if (note.event != null) {
val thread = OnlyLatestVersionSet()
val threadRoot = searchRoot(note, thread) ?: note
val threadRoot = searchRoot(note, thread) ?: note
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
loadUp(note, thread)
thread
} else {
setOf(note)
}
}
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
println("Model Refresh: Thread loaded in $elapsed")
ThreadInfo(
root = note,
allNotes = thread.toImmutableSet(),
)
} else {
ThreadInfo(
root = note,
allNotes = setOf(note).toImmutableSet(),
)
}
}
return result
fun loadUp(
note: Note,
thread: MutableSet<Note>,
) {
if (note !in thread) {
thread.add(note)
note.replyTo?.forEach { loadUp(it, thread) }
}
}
fun loadDown(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import java.lang.Long.min
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -54,13 +55,23 @@ object ThreadLevelCalculator {
now: Long,
): LevelSignature {
val replyTo = note.replyTo
// estimates the min date by replies if it doesn't exist.
val createdAt =
note.createdAt() ?: min(
note.replies.minOfOrNull { it.createdAt() ?: now } ?: now,
note.reactions.values.minOfOrNull { it.minOfOrNull { it.createdAt() ?: now } ?: now } ?: now,
)
val noteAuthor = note.author
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";",
createdAt = note.createdAt(),
author = note.author,
signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";",
createdAt = createdAt,
author = noteAuthor,
)
}
@@ -86,23 +97,21 @@ object ThreadLevelCalculator {
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == note.author && note.createdAt() != null) {
if (noteAuthor != null && parent?.author == noteAuthor) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) +
note.idHex.substring(0, 8)
} else if (note.author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies
} else if (note.author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
"9" + formattedDateTime((parent.createdAt ?: 0) + (now - createdAt)) + note.idHex.substring(0, 8)
} else if (noteAuthor != null && noteAuthor.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my replies
} else if (noteAuthor != null && noteAuthor.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else.
"0" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = note.createdAt(),
signature = "$parentSignature/$threadOrder;",
createdAt = createdAt,
author = note.author,
)
@@ -118,11 +127,17 @@ object ThreadLevelCalculator {
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
cachedLevels[note] = 0
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: replyLevel(it, cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
val thisLevel =
replyTo.maxOf {
cachedLevels[it] ?: replyLevel(it, cachedLevels)
} + 1
cachedLevels[note] = thisLevel
return thisLevel
}
}
@@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -337,19 +339,9 @@ class User(
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.map { it.lowercase() }?.toSet()?.let {
return tag.lowercase() in it
}
?: false
}
fun isFollowingHashtag(tag: String) = latestContactList?.isTaggedHash(tag) ?: false
fun isFollowingGeohash(geoTag: String): Boolean {
return latestContactList?.unverifiedFollowAddressSet()?.toSet()?.let {
return geoTag.lowercase() in it
}
?: false
}
fun isFollowingGeohash(geoTag: String) = latestContactList?.isTaggedGeoHash(geoTag) ?: false
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
@@ -370,6 +362,40 @@ class User(
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
if (hiddenWordsCase.isEmpty()) return false
if (toBestDisplayName().containsAny(hiddenWordsCase)) {
return true
}
if (profilePicture()?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.banner?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.about?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud06?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud16?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.nip05?.containsAny(hiddenWordsCase) == true) {
return true
}
return false
}
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
var liveSet: UserLiveSet? = null
@@ -22,17 +22,20 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import coil3.ImageLoader
import coil3.Uri
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.ImageRequest
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import java.util.Base64
@Stable
@@ -51,8 +54,8 @@ class Base64Fetcher(
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
return ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
@@ -66,13 +69,24 @@ class Base64Fetcher(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
return if (base64contentPattern.matcher(data.toString()).find()) {
return Base64Fetcher(options, data)
): Fetcher? =
if (data.scheme == "data") {
Base64Fetcher(options, data)
} else {
null
}
}
object BKeyer : Keyer<Uri> {
override fun key(
data: Uri,
options: Options,
): String? =
if (data.scheme == "data") {
CryptoUtils.sha256(data.toString().toByteArray()).toHexKey()
} else {
null
}
}
}
}
@@ -0,0 +1,278 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Dimension
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.File
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
class BlossomUploader(
val account: Account?,
) {
fun Context.getFileName(uri: Uri): String? =
when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
else -> uri.path?.let(::File)?.name
}
private fun Context.getContentFileName(uri: Uri): String? =
runCatching {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
}
}.getOrNull()
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: ServerName,
contentResolver: ContentResolver,
forceProxy: (String) -> Boolean,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri)
val fileName = context.getFileName(uri)
val imageInputStreamForHash = contentResolver.openInputStream(uri)
val payload =
imageInputStreamForHash?.use {
it.readBytes()
}
checkNotNull(payload) { "Can't open the image input stream" }
val hash = CryptoUtils.sha256(payload).toHexKey()
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" }
return uploadImage(
imageInputStream,
hash,
payload.size,
fileName,
myContentType,
alt,
sensitiveContent,
server,
forceProxy,
context,
)
}
suspend fun uploadImage(
inputStream: InputStream,
hash: HexKey,
length: Int,
fileName: String?,
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: ServerName,
forceProxy: (String) -> Boolean,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val fileName = randomChars()
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val apiUrl = server.baseUrl.removeSuffix("/") + "/upload"
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
val requestBuilder = Request.Builder()
val requestBody: RequestBody =
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length.toLong()
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
authUploadHeader(
hash,
alt?.let { "Uploading $it" } ?: "Uploading $fileName",
)?.let {
requestBuilder.addHeader("Authorization", it)
}
contentType?.let { requestBuilder.addHeader("Content-Type", it) }
requestBuilder
.addHeader("Content-Length", length.toString())
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(apiUrl)
.put(requestBody)
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseResults(str)
return result
}
} else {
val errorMessage = response.headers.get("X-Reason")
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (errorMessage != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
} else if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
}
}
}
}
suspend fun delete(
hash: String,
contentType: String?,
server: ServerName,
forceProxy: (String) -> Boolean,
context: Context,
): Boolean {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val apiUrl = server.baseUrl
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
val requestBuilder = Request.Builder()
authDeleteHeader(
hash,
"Deleting $hash",
)?.let { requestBuilder.addHeader("Authorization", it) }
val request =
requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(apiUrl.removeSuffix("/") + "/$hash.$extension")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
return true
} else {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
}
}
}
}
suspend fun authUploadHeader(
hash: String,
alt: String,
): String? {
val myAccount = account ?: return null
return tryAndWait { continuation ->
myAccount.createBlossomUploadAuth(hash, alt) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
continuation.resume("Nostr $encodedNIP98Event")
}
}
}
suspend fun authDeleteHeader(
hash: String,
alt: String,
): String? {
val myAccount = account ?: return null
return tryAndWait { continuation ->
myAccount.createBlossomDeleteAuth(hash, alt) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
continuation.resume("Nostr $encodedNIP98Event")
}
}
}
private fun parseResults(body: String): MediaUploadResult {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, MediaUploadResult::class.java)
}
}
data class MediaUploadResult(
// A publicly accessible URL to the BUD-01 GET /<sha256> endpoint (optionally with a file extension)
val url: String?,
// The sha256 hash of the blob
val sha256: HexKey? = null,
// The size of the blob in bytes
val size: Long? = null,
// (optional) The MIME type of the blob
val type: String? = null,
// upload time
val uploaded: Long? = null,
// dimensions
val dimension: Dimension? = null,
// magnet link
val magnet: String? = null,
val infohash: String? = null,
// ipfs link
val ipfs: String? = null,
)
@@ -20,60 +20,52 @@
*/
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
import java.net.URLDecoder
import java.net.URLEncoder
class Blurhash(
val blurhash: String,
)
@Stable
class BlurHashFetcher(
private val options: Options,
private val data: Uri,
private val data: Blurhash,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8")
val hash = data.blurhash
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
return ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<Uri> {
object Factory : Fetcher.Factory<Blurhash> {
override fun create(
data: Uri,
data: Blurhash,
options: Options,
imageLoader: ImageLoader,
): Fetcher = BlurHashFetcher(options, data)
}
}
object BlurHashRequester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.build()
object BKeyer : Keyer<Blurhash> {
override fun key(
data: Blurhash,
options: Options,
): String = data.blurhash
}
}
@@ -31,6 +31,7 @@ import android.util.Log
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Dimension
import io.trbl.blurhash.BlurHash
import kotlinx.coroutines.CancellationException
import java.io.IOException
@@ -40,14 +41,14 @@ class FileHeader(
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val dim: Dimension?,
val blurHash: String?,
) {
companion object {
suspend fun prepare(
fileUrl: String,
mimeType: String?,
dimPrecomputed: String?,
dimPrecomputed: Dimension?,
forceProxy: Boolean,
onReady: (FileHeader) -> Unit,
onError: (String) -> Unit,
@@ -70,7 +71,7 @@ class FileHeader(
fun prepare(
data: ByteArray,
mimeType: String?,
dimPrecomputed: String?,
dimPrecomputed: Dimension?,
onReady: (FileHeader) -> Unit,
onError: (String) -> Unit,
) {
@@ -95,7 +96,7 @@ class FileHeader(
mBitmap.height,
)
val dim = "${mBitmap.width}x${mBitmap.height}"
val dim = Dimension(mBitmap.width, mBitmap.height)
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
@@ -166,7 +167,7 @@ class FileHeader(
}
}
if (newDim != "0x0") {
if (newDim?.hasSize() == true) {
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
@@ -212,11 +213,15 @@ fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
}
}
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
fun MediaMetadataRetriever.prepareDimFromVideo(): Dimension? {
val width = prepareVideoWidth() ?: return null
val height = prepareVideoHeight() ?: return null
return "${width}x$height"
return if (width > 0 && height > 0) {
Dimension(width, height)
} else {
null
}
}
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
@@ -0,0 +1,239 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Looper
import android.util.Log
import android.util.LruCache
import com.fonfon.kgeohash.GeoHash
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_TIME
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
class LocationFlow(
private val context: Context,
) {
@SuppressLint("MissingPermission")
fun get(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
): Flow<Location> =
callbackFlow {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
object : LocationListener {
override fun onLocationChanged(location: Location) {
launch { send(location) }
}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
Log.d("Location Service", "LocationState Start")
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
awaitClose {
locationManager.removeUpdates(locationCallback)
Log.d("Location Service", "LocationState Stop")
}
}
}
class LocationState(
context: Context,
scope: CoroutineScope,
) {
companion object {
const val MIN_TIME: Long = 10000L
const val MIN_DISTANCE: Float = 100.0f
}
sealed class LocationResult {
data class Success(
val geoHash: GeoHash,
) : LocationResult()
object LackPermission : LocationResult()
object Loading : LocationResult()
}
private var hasLocationPermission = MutableStateFlow<Boolean>(false)
private var latestLocation: LocationResult = LocationResult.Loading
fun setLocationPermission(newValue: Boolean) {
if (newValue != hasLocationPermission.value) {
hasLocationPermission.tryEmit(newValue)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val geohashStateFlow =
hasLocationPermission
.transformLatest {
emitAll(
LocationFlow
(context)
.get(MIN_TIME, MIN_DISTANCE)
.map {
LocationResult.Success(it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)) as LocationResult
}.onEach {
latestLocation = it
}.catch { e ->
e.printStackTrace()
latestLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
},
)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
}
object CachedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
suspend fun geoLocate(
geoHashStr: String,
location: Location,
context: Context,
): String? {
locationNames[geoHashStr]?.let {
return it
}
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
if (name != null) {
locationNames.put(geoHashStr, name)
}
return name
}
}
private class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context,
): String? {
return try {
Geocoder(context)
.getFromLocation(
location.latitude,
location.longitude,
1,
)?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
return null
}
}
}
class ReverseGeoLocationFlow(
private val context: Context,
) {
@SuppressLint("MissingPermission")
fun get(location: Location): Flow<String?> =
callbackFlow {
val locationManager = Geocoder(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val locationCallback =
(
Geocoder.GeocodeListener { addresses ->
launch {
send(
addresses.firstOrNull()?.let {
listOfNotNull(it.locality ?: it.subAdminArea, it.countryCode).joinToString(", ")
},
)
}
}
)
Log.d("GeoLocation Service", "LocationState Start")
locationManager
.getFromLocation(
location.latitude,
location.longitude,
1,
locationCallback,
)
} else {
launch {
send(
Geocoder(context)
.getFromLocation(
location.latitude,
location.longitude,
1,
)?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
},
)
}
}
}
}
@@ -1,141 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.HandlerThread
import android.util.LruCache
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(
context: Context,
) {
companion object {
const val MIN_TIME: Long = 1000L
const val MIN_DISTANCE: Float = 0.0f
}
private val locationManager =
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var locationListener: LocationListener? = null
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
val providerState = mutableStateOf(false)
val isStart: MutableState<Boolean> = mutableStateOf(false)
private val locHandlerThread = HandlerThread("LocationUtil Thread")
init {
locHandlerThread.start()
}
@SuppressLint("MissingPermission")
fun start(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
) {
locationListener().let {
locationListener = it
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
minTimeMs,
minDistanceM,
it,
locHandlerThread.looper,
)
}
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
isStart.value = true
}
fun stop() {
locationListener?.let { locationManager.removeUpdates(it) }
isStart.value = false
}
private fun locationListener() =
object : LocationListener {
override fun onLocationChanged(location: Location) {
locationStateFlow.value = location
}
override fun onProviderEnabled(provider: String) {
providerState.value = true
}
override fun onProviderDisabled(provider: String) {
providerState.value = false
}
}
}
object CachedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
suspend fun geoLocate(
geoHashStr: String,
location: Location,
context: Context,
): String? {
locationNames[geoHashStr]?.let {
return it
}
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
if (name != null) {
locationNames.put(geoHashStr, name)
}
return name
}
}
private class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context,
): String? {
return try {
Geocoder(context)
.getFromLocation(location.latitude, location.longitude, 1)
?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
return null
}
}
}
@@ -31,20 +31,6 @@ import java.net.URI
import java.net.URL
object Nip96MediaServers {
val DEFAULT =
listOf(
ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("NostPic", "https://nostpic.com"),
ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat"),
)
data class ServerName(
val name: String,
val baseUrl: String,
)
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
suspend fun load(
@@ -136,23 +122,23 @@ class Nip96Retriever {
}
}
}
fun makeAbsoluteIfRelativeUrl(
baseUrl: String,
potentialyRelativeUrl: String,
): String =
try {
val apiUrl = URI(potentialyRelativeUrl)
if (apiUrl.isAbsolute) {
potentialyRelativeUrl
} else {
URL(URL(baseUrl), potentialyRelativeUrl).toString()
}
} catch (e: Exception) {
potentialyRelativeUrl
}
}
typealias PlanName = String
typealias MimeType = String
fun makeAbsoluteIfRelativeUrl(
baseUrl: String,
potentialyRelativeUrl: String,
): String =
try {
val apiUrl = URI(potentialyRelativeUrl)
if (apiUrl.isAbsolute) {
potentialyRelativeUrl
} else {
URL(URL(baseUrl), potentialyRelativeUrl).toString()
}
} catch (e: Exception) {
potentialyRelativeUrl
}
@@ -32,11 +32,12 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.Dimension
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
@@ -60,12 +61,12 @@ class Nip96Uploader(
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96MediaServers.ServerName,
server: ServerName,
contentResolver: ContentResolver,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
): MediaUploadResult {
val serverInfo =
Nip96Retriever()
.loadInfo(
@@ -98,7 +99,7 @@ class Nip96Uploader(
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
): MediaUploadResult {
checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri)
@@ -138,7 +139,7 @@ class Nip96Uploader(
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
context: Context,
): PartialEvent {
): MediaUploadResult {
checkNotInMainThread()
val fileName = randomChars()
@@ -190,7 +191,7 @@ class Nip96Uploader(
if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, forceProxy, onProgress)
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
return convertToMediaResult(result.nip94Event)
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
}
@@ -224,6 +225,40 @@ class Nip96Uploader(
}
}
fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult {
// Images don't seem to be ready immediately after upload
val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
?.let { Dimension.parse(it) }
val magnet =
nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
return MediaUploadResult(
url = imageUrl,
type = remoteMimeType,
sha256 = originalHash,
dimension = dim,
magnet = magnet,
)
}
suspend fun delete(
hash: String,
contentType: String?,
@@ -270,7 +305,7 @@ class Nip96Uploader(
server: Nip96Retriever.ServerInfo,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
): MediaUploadResult {
var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
@@ -297,17 +332,15 @@ class Nip96Uploader(
val nip94 = currentResult.nip94Event
if (nip94 != null) {
return nip94
return convertToMediaResult(nip94)
} else {
throw RuntimeException("Error waiting for processing. Final result is unavailable")
}
}
suspend fun nip98Header(url: String): String? =
withTimeoutOrNull(5000) {
suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
}
tryAndWait { continuation ->
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
}
fun nip98Header(
@@ -33,14 +33,17 @@ import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BlossomServersEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
@@ -53,6 +56,8 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -78,44 +83,25 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
fun createAccountContactListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
fun createAccountMetadataFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
),
)
fun createAccountRelayListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
),
authors = listOf(account.userProfile().pubkeyHex),
limit = 10,
limit = 20,
),
)
@@ -134,11 +120,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
BlossomServersEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
authors = otherAuthors,
limit = 100,
limit = otherAuthors.size * 20,
),
)
}
@@ -154,6 +141,18 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
),
)
fun createAccountSettings2Filter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds = listOf(AppSpecificDataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)),
limit = 1,
),
)
fun createAccountReportsFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
@@ -244,9 +243,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
GitIssueEvent.KIND,
GitPatchEvent.KIND,
HighlightEvent.KIND,
CommentEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarTimeSlotEvent.KIND,
CalendarRSVPEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
@@ -465,8 +467,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
accountChannel.typedFilters =
listOfNotNull(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountSettings2Filter(),
createNotificationFilter(),
createNotificationFilter2(),
createGiftWrapsToMeFilter(),
@@ -480,9 +481,8 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
accountChannel.typedFilters =
listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountSettingsFilter(),
createAccountSettings2Filter(),
).ifEmpty { null }
}
@@ -162,7 +162,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
fun createLiveStreamFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.authors
?.toList()
?.ifEmpty { null }
@@ -27,8 +27,8 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -57,12 +57,12 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
limit = 200,
),
@@ -27,7 +27,9 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
@@ -37,45 +39,59 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = hashtagToWatch ?: return null
fun createLoadHashtagFilter(): List<TypedFilter> {
val hashToLoad = hashtagToWatch ?: return emptyList()
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags =
mapOf(
"t" to
listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize(),
),
),
kinds =
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
),
limit = 200,
),
val hashtagsToFollow =
listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize(),
)
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags = mapOf("t" to hashtagsToFollow),
kinds =
listOf(
TextNoteEvent.KIND,
ChannelMessageEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
limit = 200,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags = mapOf("t" to hashtagsToFollow),
kinds =
listOf(
InteractiveStorySceneEvent.KIND,
),
limit = 200,
),
),
)
}
val loadHashtagChannel = requestNewChannel()
override fun updateChannelFilters() {
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
loadHashtagChannel.typedFilters = createLoadHashtagFilter().ifEmpty { null }
}
fun loadHashtag(tag: String?) {
@@ -31,9 +31,11 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
@@ -85,38 +87,57 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
job2?.cancel()
}
fun createFollowAccountsFilter(): TypedFilter {
fun createFollowAccountsFilter(): List<TypedFilter> {
val follows =
account.liveHomeListAuthorsPerRelay.value
return TypedFilter(
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = follows,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
return listOf(
TypedFilter(
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
),
authors = follows,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
),
TypedFilter(
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
kinds =
listOf(
InteractiveStoryPrologueEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = follows,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultHomeFollowList.value)
?.relayList,
),
),
)
}
@@ -164,8 +185,8 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND,
WikiNoteEvent.KIND,
),
tags =
@@ -202,16 +223,15 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.map { listOf(it.lowercase()) }
.flatten(),
),
limit = 100,
@@ -225,7 +245,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
}
fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
val communitiesToLoad = account.liveHomeFollowLists.value?.addresses ?: return null
if (communitiesToLoad.isEmpty()) return null
@@ -239,11 +259,10 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
CommentEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
),
tags =
mapOf(
@@ -271,12 +290,14 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
override fun updateChannelFilters() {
followAccountChannel.typedFilters =
listOfNotNull(
createFollowAccountsFilter(),
createFollowMetadataAndReleaseFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
(
createFollowAccountsFilter() +
listOfNotNull(
createFollowMetadataAndReleaseFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
)
).ifEmpty { null }
}
}
@@ -38,9 +38,12 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -177,6 +180,20 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
SincePerRelayFilter(
kinds =
listOf(
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
),
search = mySearchString,
limit = 100,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.TypedFilter
@@ -28,26 +29,55 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val threadToLoad = eventToWatch ?: return null
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter> {
val threadToLoad = eventToWatch ?: return emptyList()
val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList()
val eventsToLoad =
ThreadAssembler()
.findThreadFor(threadToLoad)
branch.allNotes
.filter { it.event == null }
.map { it.idHex }
.toSet()
.ifEmpty { null }
?: return null
if (eventsToLoad.isEmpty()) return null
val address = if (branch.root is AddressableNote) branch.root.idHex else null
val event = if (branch.root !is AddressableNote) branch.root.idHex else branch.root.event?.id()
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
ids = eventsToLoad.toList(),
),
return listOfNotNull(
eventsToLoad?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
ids = it.toList(),
),
)
},
event?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags =
mapOf(
"e" to listOf(event),
),
),
)
},
address?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
tags =
mapOf(
"a" to listOf(address),
),
),
)
},
)
}
@@ -59,8 +89,7 @@ object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
}
override fun updateChannelFilters() {
loadEventsChannel.typedFilters =
listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter()
}
fun loadThread(noteId: String?) {
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -101,6 +102,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
listOf(
TorrentEvent.KIND,
TorrentCommentEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
),
authors = listOf(it.pubkeyHex),
limit = 20,
@@ -29,6 +29,7 @@ import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.PictureEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import kotlinx.coroutines.Dispatchers
@@ -64,85 +65,139 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
job?.cancel()
}
fun createContextualFilter(): TypedFilter {
fun createContextualFilter(): List<TypedFilter> {
val follows = account.liveStoriesListAuthorsPerRelay.value
return TypedFilter(
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
val types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS)
return listOf(
TypedFilter(
types = types,
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
TypedFilter(
types = types,
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
)
}
fun createFollowTagsFilter(): TypedFilter? {
fun createFollowTagsFilter(): List<TypedFilter> {
val hashToLoad =
account.liveStoriesFollowLists.value
?.hashtags
?.toList() ?: return null
?.toList() ?: return emptyList()
if (hashToLoad.isEmpty()) return null
if (hashToLoad.isEmpty()) return emptyList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
val hashtags =
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten()
return listOf(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags = mapOf("t" to hashtags),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"t" to hashtags,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
fun createFollowGeohashesFilter(): List<TypedFilter> {
val hashToLoad =
account.liveStoriesFollowLists.value
?.geotags
?.toList() ?: return null
?.toList() ?: return emptyList()
if (hashToLoad.isEmpty()) return null
if (hashToLoad.isEmpty()) return emptyList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
val geoHashes =
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten()
return listOf(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags = mapOf("g" to geoHashes),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
tags =
mapOf(
"g" to geoHashes,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.settings.defaultStoriesFollowList.value)
?.relayList,
),
),
)
}
@@ -162,6 +217,6 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
createContextualFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),
).ifEmpty { null }
).flatten().ifEmpty { null }
}
}
@@ -23,13 +23,12 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.collectSuccessfulOperations
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppDefinitionEvent
@@ -195,8 +194,8 @@ class ZapPaymentHandler(
)?.readRelays()
}?.toSet()
collectSuccessfulSigningOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
operationsInput = zapsToSend,
collectSuccessfulOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
items = zapsToSend,
runRequestFor = { next: ZapSplitSetup, onReady ->
if (next.isLnAddress) {
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
@@ -239,8 +238,8 @@ class ZapPaymentHandler(
var progressAllPayments = 0.00f
val totalWeight = invoices.sumOf { it.first.weight }
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
operationsInput = invoices,
collectSuccessfulOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
items = invoices,
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, SignAllZapRequestsReturn>, onReady ->
assembleInvoice(
splitSetup = splitZapRequestPair.first,
@@ -272,8 +271,8 @@ class ZapPaymentHandler(
) {
var progressAllPayments = 0.00f
collectSuccessfulSigningOperations<String, Boolean>(
operationsInput = invoices,
collectSuccessfulOperations<String, Boolean>(
items = invoices,
runRequestFor = { invoice: String, onReady ->
account.sendZapPaymentRequestFor(
bolt11 = invoice,
@@ -154,8 +154,9 @@ class EventNotificationConsumer(
acc: AccountSettings,
) {
if (
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
// old event being re-broadcasted
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
// don't display if it comes from me.
event.pubKey != signer.pubKey
) { // from the user
Log.d(TAG, "Notifying")
@@ -29,9 +29,10 @@ import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import coil.ImageLoader
import coil.executeBlocking
import coil.request.ImageRequest
import coil3.ImageLoader
import coil3.asDrawable
import coil3.executeBlocking
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
@@ -157,7 +158,7 @@ object NotificationUtils {
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = imageResult.drawable as? BitmapDrawable,
picture = imageResult.image?.asDrawable(applicationContext.resources) as? BitmapDrawable,
uri = uri,
channelId,
notificationGroupKey,
@@ -25,18 +25,15 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -63,38 +60,26 @@ class RegisterAccounts(
return
}
coroutineScope {
val jobs =
remainingTos.map { accountRelayPair ->
async {
val result =
withTimeoutOrNull(10000) {
suspendCancellableCoroutine { continuation ->
val signer = accountRelayPair.first.createSigner()
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
if (signer is NostrSignerExternal) {
signer.launcher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
launchAndWaitAll(remainingTos) { accountRelayPair ->
val result =
tryAndWait { continuation ->
val signer = accountRelayPair.first.createSigner()
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
if (signer is NostrSignerExternal) {
signer.launcher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
continuation.resume(result)
}
}
}
if (result != null) {
output.add(result)
}
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
continuation.resume(result)
}
}
// runs in parallel to avoid overcrowding Amber.
withTimeoutOrNull(15000) {
jobs.joinAll()
if (result != null) {
output.add(result)
}
}
@@ -85,11 +85,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
@@ -97,6 +98,9 @@ import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ImageVideoDescription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.UserLine
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -333,8 +337,8 @@ fun EditPostView(
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
if (!server.isNip95) {
accountViewModel.account.settings.changeDefaultFileServer(server.server)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
}
},
onCancel = { postViewModel.contentToAddUrl = null },
@@ -38,9 +38,13 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -150,7 +154,7 @@ open class EditPostViewModel : ViewModel() {
sensitiveContent: Boolean,
mediaQuality: Int,
isPrivate: Boolean = false,
server: ServerOption,
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
) {
@@ -167,7 +171,7 @@ open class EditPostViewModel : ViewModel() {
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server.isNip95) {
if (server.type == ServerType.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(
it.readBytes(),
@@ -180,7 +184,7 @@ open class EditPostViewModel : ViewModel() {
context,
)
}
} else {
} else if (server.type == ServerType.NIP96) {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
@@ -191,13 +195,52 @@ open class EditPostViewModel : ViewModel() {
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server.server,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = {},
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
} else {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
@@ -274,7 +317,7 @@ open class EditPostViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.findUsersStartingWith(lastWord.removePrefix("@"), account)
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
@@ -316,7 +359,7 @@ open class EditPostViewModel : ViewModel() {
contentToAddUrl == null
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
uploadingResult: MediaUploadResult,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
@@ -324,30 +367,7 @@ open class EditPostViewModel : ViewModel() {
onError: (String) -> Unit = {},
context: Context,
) {
// Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
val magnet =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
if (uploadingResult.url.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
@@ -356,16 +376,16 @@ open class EditPostViewModel : ViewModel() {
}
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
forceProxy = forceProxy(imageUrl),
fileUrl = uploadingResult.url,
mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = uploadingResult.dimension,
forceProxy = forceProxy(uploadingResult.url),
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
isUploadingImage = false
nip94attachments = nip94attachments + event
message = message.insertUrlAtCursor(imageUrl)
message = message.insertUrlAtCursor(uploadingResult.url)
urlPreview = findUrlInMessage()
}
},
@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -47,6 +47,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -31,9 +31,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
@@ -41,11 +45,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
data class ServerOption(
val server: Nip96MediaServers.ServerName,
val isNip95: Boolean,
)
@Stable
open class NewMediaModel : ViewModel() {
var account: Account? = null
@@ -53,7 +52,7 @@ open class NewMediaModel : ViewModel() {
var isUploadingImage by mutableStateOf(false)
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServerOption?>(null)
var selectedServer by mutableStateOf<ServerName?>(null)
var alt by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
@@ -73,7 +72,7 @@ open class NewMediaModel : ViewModel() {
this.account = account
this.galleryUri = uri
this.mediaType = contentType
this.selectedServer = ServerOption(defaultServer(), false)
this.selectedServer = defaultServer()
}
fun upload(
@@ -99,7 +98,7 @@ open class NewMediaModel : ViewModel() {
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (serverToUse.isNip95) {
if (serverToUse.type == ServerType.NIP95) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
@@ -121,7 +120,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = null
}
}
} else {
} else if (serverToUse.type == ServerType.NIP96) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) {
@@ -134,7 +133,7 @@ open class NewMediaModel : ViewModel() {
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse.server,
server = serverToUse,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = { percent: Float ->
@@ -143,6 +142,43 @@ open class NewMediaModel : ViewModel() {
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = onError,
context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
}
}
} else if (serverToUse.type == ServerType.Blossom) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
@@ -182,13 +218,13 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.0f
alt = ""
selectedServer = ServerOption(defaultServer(), false)
selectedServer = defaultServer()
}
fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
uploadingResult: MediaUploadResult,
localContentType: String?,
alt: String,
sensitiveContent: Boolean,
@@ -201,29 +237,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
val magnet =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
if (uploadingResult.url.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
@@ -236,7 +250,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl, forceProxy(imageUrl))
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadingResult.url, forceProxy(uploadingResult.url))
if (imageData != null) {
uploadingPercentage.value = 0.80f
@@ -244,18 +258,18 @@ open class NewMediaModel : ViewModel() {
FileHeader.prepare(
data = imageData,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = uploadingResult.dimension,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
account?.sendHeader(
imageUrl,
magnet,
uploadingResult.url,
uploadingResult.magnet,
it,
alt,
sensitiveContent,
originalHash,
uploadingResult.sha256,
relayList,
) {
uploadingPercentage.value = 1.00f
@@ -338,7 +352,7 @@ open class NewMediaModel : ViewModel() {
fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded
@@ -30,8 +30,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
@@ -40,17 +42,21 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -67,23 +73,28 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingSwitchItem
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.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
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
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewMediaView(
uri: Uri,
@@ -118,9 +129,66 @@ fun NewMediaView(
decorFitsSystemWindows = false,
),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
) {
SetDialogToEdgeToEdge()
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = { showRelaysDialog = true },
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
PostButton(
onPost = {
onClose()
postViewModel.upload(context, relayList, mediaQualitySlider) {
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
}
postViewModel.selectedServer?.let {
if (it.type != ServerType.NIP95) {
account.settings.changeDefaultFileServer(it)
}
}
},
isActive = postViewModel.canPost(),
)
}
},
navigationIcon = {
Row {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.cancel()
onClose()
},
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
if (showRelaysDialog) {
RelaySelectionDialog(
preSelectedList = relayList,
@@ -131,61 +199,21 @@ fun NewMediaView(
)
}
Column(
Surface(
modifier =
Modifier
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.fillMaxWidth()
.fillMaxHeight()
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(
onPress = {
postViewModel.cancel()
onClose()
},
)
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = { showRelaysDialog = true },
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
PostButton(
onPost = {
onClose()
postViewModel.upload(context, relayList, mediaQualitySlider) {
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
}
postViewModel.selectedServer?.let {
if (!it.isNip95) {
account.settings.changeDefaultFileServer(it.server)
}
}
},
isActive = postViewModel.canPost(),
)
}
Row(
modifier = Modifier.fillMaxWidth().weight(1f),
Column(
modifier =
Modifier
.fillMaxSize()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
) {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
modifier = Modifier.fillMaxWidth().weight(1f).verticalScroll(scrollState),
) {
ImageVideoPost(postViewModel, accountViewModel)
@@ -194,7 +222,6 @@ fun NewMediaView(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(vertical = 8.dp),
) {
Column(
@@ -254,39 +281,21 @@ fun ImageVideoPost(
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
) {
val listOfNip96ServersNote =
accountViewModel.account
.getFileServersNote()
.live()
.metadata
.observeAsState()
val fileServers =
(
(listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map {
ServerOption(
Nip96MediaServers.ServerName(
it,
it,
),
false,
)
} ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) }
) +
listOf(
ServerOption(
Nip96MediaServers.ServerName(
"NIP95",
stringRes(id = R.string.upload_server_relays_nip95),
),
true,
),
)
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
val fileServers by accountViewModel.account.liveServerList.collectAsState()
val fileServerOptions =
remember {
fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList()
remember(fileServers) {
fileServers
.map {
if (it.type == ServerType.NIP95) {
TitleExplainer(it.name, nip95description)
} else {
TitleExplainer(it.name, it.baseUrl)
}
}.toImmutableList()
}
val resolver = LocalContext.current.contentResolver
Row(
@@ -353,10 +362,9 @@ fun ImageVideoPost(
label = stringRes(id = R.string.file_server),
placeholder =
fileServers
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
?.server
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
?.name
?: fileServers[0].server.name,
?: fileServers[0].name,
options = fileServerOptions,
onSelect = { postViewModel.selectedServer = fileServers[it] },
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
@@ -39,29 +39,23 @@ class NewMessageTagger(
var dao: Dao,
) {
val directMentions = mutableSetOf<HexKey>()
val directMentionsNotes = mutableSetOf<Note>()
val directMentionsUsers = mutableSetOf<User>()
fun addUserToMentions(user: User) {
directMentionsUsers.add(user)
directMentions.add(user.pubkeyHex)
pTags = if (pTags?.contains(user) == true) pTags else pTags?.plus(user) ?: listOf(user)
}
fun addNoteToReplyTos(note: Note) {
directMentionsNotes.add(note)
directMentions.add(note.idHex)
note.author?.let { addUserToMentions(it) }
eTags = if (eTags?.contains(note) == true) eTags else eTags?.plus(note) ?: listOf(note)
}
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channelHex != null) 1 else 0) + (eTags?.size ?: 0) + (pTags?.indexOf(user) ?: 0)
}
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channelHex != null) 1 else 0) + (eTags?.indexOf(note) ?: 0)
}
suspend fun run() {
// adds all references to mentions and reply tos
message.split('\n').forEach { paragraph: String ->
@@ -34,7 +34,7 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -42,10 +42,14 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -59,6 +63,7 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
@@ -68,6 +73,7 @@ import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.RootScope
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
@@ -75,10 +81,8 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.events.findURLs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
@@ -163,8 +167,8 @@ open class NewPostViewModel : ViewModel() {
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
var locUtil: LocationUtil? = null
var location: Flow<String>? = null
var location: StateFlow<LocationState.LocationResult>? = null
var wantsExclusiveGeoPost by mutableStateOf(false)
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
@@ -220,7 +224,10 @@ open class NewPostViewModel : ViewModel() {
if (replyNote.event !is CommunityDefinitionEvent) {
replyNote.author?.let { replyUser ->
val currentMentions =
(replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) }
(replyNote.event as? TextNoteEvent)
?.mentions()
?.filter { it.isNotEmpty() }
?.map { LocalCache.getOrCreateUser(it) }
?: emptyList()
if (currentMentions.contains(replyUser)) {
@@ -336,7 +343,13 @@ open class NewPostViewModel : ViewModel() {
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" }
wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" }
val geohash = draftEvent.getGeoHash()
wantsToAddGeoHash = geohash != null
if (geohash != null) {
wantsExclusiveGeoPost = draftEvent.kind() == CommentEvent.KIND
}
val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" }
wantsZapraiser = zapraiser.isNotEmpty()
zapRaiserAmount = null
@@ -530,14 +543,7 @@ open class NewPostViewModel : ViewModel() {
null
}
val geoLocation = locUtil?.locationStateFlow?.value
val geoHash =
if (wantsToAddGeoHash && geoLocation != null) {
geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString()
} else {
null
}
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
nip95attachments.forEach {
@@ -551,7 +557,37 @@ open class NewPostViewModel : ViewModel() {
// Doesn't send as nip94 yet because we don't know if it makes sense.
// usedAttachments.forEach { account?.sendHeader(it, relayList, {}) }
if (originalNote?.channelHex() != null) {
val replyingTo = originalNote
if (replyingTo?.event is CommentEvent || (replyingTo?.event is Event && replyingTo.event is RootScope)) {
account?.sendReplyComment(
message = tagger.message,
replyingTo = replyingTo,
directMentionsUsers = tagger.directMentionsUsers,
directMentionsNotes = tagger.directMentionsNotes,
nip94attachments = usedAttachments,
geohash = geoHash,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
draftTag = localDraft,
)
} else if (wantsExclusiveGeoPost && geoHash != null && (originalNote == null || originalNote?.event is CommentEvent)) {
account?.sendGeoComment(
message = tagger.message,
geohash = geoHash,
replyingTo = originalNote,
directMentionsUsers = tagger.directMentionsUsers,
directMentionsNotes = tagger.directMentionsNotes,
nip94attachments = usedAttachments,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
draftTag = localDraft,
)
} else if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(
message = tagger.message,
@@ -836,7 +872,7 @@ open class NewPostViewModel : ViewModel() {
sensitiveContent: Boolean,
mediaQuality: Int,
isPrivate: Boolean = false,
server: ServerOption,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
) {
@@ -853,7 +889,7 @@ open class NewPostViewModel : ViewModel() {
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server.isNip95) {
if (server.type == ServerType.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(
it.readBytes(),
@@ -866,7 +902,7 @@ open class NewPostViewModel : ViewModel() {
context,
)
}
} else {
} else if (server.type == ServerType.NIP96) {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
@@ -877,13 +913,52 @@ open class NewPostViewModel : ViewModel() {
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server.server,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = {},
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
} else if (server.type == ServerType.Blossom) {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
@@ -953,6 +1028,7 @@ open class NewPostViewModel : ViewModel() {
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
wantsExclusiveGeoPost = false
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
@@ -981,6 +1057,10 @@ open class NewPostViewModel : ViewModel() {
draftTextChanges.trySend("")
}
open fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
}
open fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
@@ -998,7 +1078,7 @@ open class NewPostViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.findUsersStartingWith(lastWord.removePrefix("@"), account)
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
@@ -1027,7 +1107,7 @@ open class NewPostViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.findUsersStartingWith(lastWord.removePrefix("@"), account)
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
@@ -1055,7 +1135,7 @@ open class NewPostViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.findUsersStartingWith(lastWord.removePrefix("@"), account)
.sortedWith(
compareBy(
{ account?.isFollowing(it) },
@@ -1144,7 +1224,7 @@ open class NewPostViewModel : ViewModel() {
contentToAddUrl == null
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
uploadingResult: MediaUploadResult,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
@@ -1152,30 +1232,7 @@ open class NewPostViewModel : ViewModel() {
onError: (message: String) -> Unit,
context: Context,
) {
// Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
val magnet =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
if (uploadingResult.url.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
@@ -1184,16 +1241,16 @@ open class NewPostViewModel : ViewModel() {
}
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
forceProxy = forceProxy(imageUrl),
fileUrl = uploadingResult.url,
mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = uploadingResult.dimension,
forceProxy = forceProxy(uploadingResult.url),
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
isUploadingImage = false
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
message = message.insertUrlAtCursor(imageUrl)
message = message.insertUrlAtCursor(uploadingResult.url)
urlPreview = findUrlInMessage()
saveDraft()
}
@@ -1257,29 +1314,17 @@ open class NewPostViewModel : ViewModel() {
contentToAddUrl = uri
}
@OptIn(ExperimentalCoroutinesApi::class)
fun startLocation(context: Context) {
locUtil = LocationUtil(context)
locUtil?.let {
location =
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
saveDraft()
fun locationFlow(): StateFlow<LocationState.LocationResult> {
if (location == null) {
location = Amethyst.instance.locationManager.geohashStateFlow
}
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
}
fun stopLocation() {
viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() }
location = null
locUtil = null
return location!!
}
override fun onCleared() {
super.onCleared()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() }
location = null
locUtil = null
}
fun toggleNIP04And24() {
@@ -1382,7 +1427,7 @@ open class NewPostViewModel : ViewModel() {
elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" }
}
}
elementList.removeLast()
elementList.removeAt(elementList.size - 1)
val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) }
this.clear()
this.putAll(newEntries)
@@ -46,6 +46,8 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -187,6 +189,22 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.pronouns)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.pronouns.value,
onValueChange = { postViewModel.pronouns.value = it },
placeholder = {
Text(
text = "they/them, ...",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.website_url)) },
modifier = Modifier.fillMaxWidth(),
@@ -29,7 +29,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.stringRes
@@ -51,6 +53,7 @@ class NewUserMetadataViewModel : ViewModel() {
val banner = mutableStateOf("")
val website = mutableStateOf("")
val pronouns = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
@@ -72,6 +75,7 @@ class NewUserMetadataViewModel : ViewModel() {
picture.value = it.info?.picture ?: ""
banner.value = it.info?.banner ?: ""
website.value = it.info?.website ?: ""
pronouns.value = it.info?.pronouns ?: ""
nip05.value = it.info?.nip05 ?: ""
lnAddress.value = it.info?.lud16 ?: ""
lnURL.value = it.info?.lud06 ?: ""
@@ -99,6 +103,7 @@ class NewUserMetadataViewModel : ViewModel() {
picture = picture.value,
banner = banner.value,
website = website.value,
pronouns = pronouns.value,
about = about.value,
nip05 = nip05.value,
lnAddress = lnAddress.value,
@@ -178,25 +183,38 @@ class NewUserMetadataViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
Nip96Uploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = null,
sensitiveContent = null,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
forceProxy = account::shouldUseTorForNIP96,
onProgress = {},
context = context,
)
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = null,
sensitiveContent = null,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
forceProxy = account::shouldUseTorForNIP96,
onProgress = {},
context = context,
)
} else {
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = null,
sensitiveContent = null,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
forceProxy = account::shouldUseTorForNIP96,
context = context,
)
}
val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
if (url != null) {
if (result.url != null) {
onUploading(false)
onUploaded(url)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
@@ -50,6 +50,8 @@ import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.relays.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
@@ -22,9 +22,10 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@@ -55,11 +56,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -69,7 +66,6 @@ import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(
isUploading: Boolean,
@@ -77,32 +73,19 @@ fun UploadFromGallery(
modifier: Modifier,
onImageChosen: (Uri) -> Unit,
) {
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_IMAGES
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
showGallerySelect = false
if (uri != null) {
onImageChosen(uri)
}
},
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
showGallerySelect = false
if (uri != null) {
onImageChosen(uri)
}
},
)
}
UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true }
} else {
UploadBoxButton(isUploading, tint, modifier) { cameraPermissionState.launchPermissionRequest() }
}
UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true }
}
@Composable
@@ -183,6 +166,7 @@ fun LoadingAnimation(
easing = LinearEasing,
),
),
label = "UploadGalleryUploadingAnimation",
)
CircularProgressIndicator(
@@ -203,10 +187,10 @@ fun LoadingAnimation(
@Composable
fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) {
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val launcher =
rememberLauncherForActivityResult(
contract = GetMediaActivityResultContract(),
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri: Uri? ->
onImageUri(uri)
hasLaunched.set(false)
@@ -217,7 +201,7 @@ fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) {
fun LaunchGallery() {
SideEffect {
if (!hasLaunched.getAndSet(true)) {
launcher.launch("*/*")
launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
}
}
}
@@ -54,13 +54,14 @@ 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.SettingsCategory
import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -73,11 +74,15 @@ fun MediaServersListView(
accountViewModel: AccountViewModel,
nav: INav,
) {
val mediaServersViewModel: MediaServersViewModel = viewModel()
val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle()
val nip96ServersViewModel: NIP96ServersViewModel = viewModel()
val nip96ServersState by nip96ServersViewModel.fileServers.collectAsStateWithLifecycle()
val blossomServersViewModel: BlossomServersViewModel = viewModel()
val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle()
LaunchedEffect(key1 = Unit) {
mediaServersViewModel.load(accountViewModel.account)
nip96ServersViewModel.load(accountViewModel.account)
blossomServersViewModel.load(accountViewModel.account)
}
Dialog(
@@ -102,7 +107,8 @@ fun MediaServersListView(
navigationIcon = {
CloseButton(
onPress = {
mediaServersViewModel.refresh()
nip96ServersViewModel.refresh()
blossomServersViewModel.refresh()
onClose()
},
)
@@ -110,7 +116,8 @@ fun MediaServersListView(
actions = {
SaveButton(
onPost = {
mediaServersViewModel.saveFileServers()
nip96ServersViewModel.saveFileServers()
blossomServersViewModel.saveFileServers()
onClose()
},
isActive = true,
@@ -148,17 +155,48 @@ fun MediaServersListView(
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = FeedPadding,
) {
item {
SettingsCategory(
stringRes(R.string.media_servers_nip96_section),
stringRes(R.string.media_servers_nip96_explainer),
Modifier.padding(bottom = 8.dp),
)
}
renderMediaServerList(
mediaServersState = mediaServersState,
mediaServersState = nip96ServersState,
keyType = "nip96",
editLabel = R.string.add_a_nip96_server,
emptyLabel = R.string.no_nip96_server_message,
onAddServer = { server ->
mediaServersViewModel.addServer(server)
nip96ServersViewModel.addServer(server)
},
onDeleteServer = {
mediaServersViewModel.removeServer(serverUrl = it)
nip96ServersViewModel.removeServer(serverUrl = it)
},
)
Nip96MediaServers.DEFAULT.let {
item {
SettingsCategory(
stringRes(R.string.media_servers_blossom_section),
stringRes(R.string.media_servers_blossom_explainer),
)
}
renderMediaServerList(
mediaServersState = blossomServersState,
keyType = "blossom",
editLabel = R.string.add_a_blossom_server,
emptyLabel = R.string.no_blossom_server_message,
onAddServer = { server ->
blossomServersViewModel.addServer(server)
},
onDeleteServer = {
blossomServersViewModel.removeServer(serverUrl = it)
},
)
DEFAULT_MEDIA_SERVERS.let {
item {
SettingsCategoryWithButton(
title = stringRes(id = R.string.built_in_media_servers_title),
@@ -166,7 +204,13 @@ fun MediaServersListView(
action = {
OutlinedButton(
onClick = {
mediaServersViewModel.addServerList(it.map { s -> s.baseUrl })
nip96ServersViewModel.addServerList(
it.mapNotNull { s -> if (s.type == ServerType.NIP96) s.baseUrl else null },
)
blossomServersViewModel.addServerList(
it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null },
)
},
) {
Text(text = stringRes(id = R.string.use_default_servers))
@@ -176,19 +220,27 @@ fun MediaServersListView(
}
itemsIndexed(
it,
key = { index: Int, server: Nip96MediaServers.ServerName ->
server.baseUrl
key = { index: Int, server: ServerName ->
"Proposed" + server.baseUrl
},
) { index, server ->
MediaServerEntry(
serverEntry = server,
isAmethystDefault = true,
onAddOrDelete = { serverUrl ->
mediaServersViewModel.addServer(serverUrl)
if (server.type == ServerType.NIP96) {
nip96ServersViewModel.addServer(serverUrl)
} else if (server.type == ServerType.Blossom) {
blossomServersViewModel.addServer(serverUrl)
}
},
)
}
}
item {
Spacer(DoubleHorzSpacer)
}
}
}
}
@@ -196,22 +248,25 @@ fun MediaServersListView(
}
fun LazyListScope.renderMediaServerList(
mediaServersState: List<Nip96MediaServers.ServerName>,
mediaServersState: List<ServerName>,
keyType: String,
editLabel: Int,
emptyLabel: Int,
onAddServer: (String) -> Unit,
onDeleteServer: (String) -> Unit,
) {
if (mediaServersState.isEmpty()) {
item {
Text(
text = stringRes(id = R.string.no_media_server_message),
text = stringRes(id = emptyLabel),
modifier = DoubleVertPadding,
)
}
} else {
itemsIndexed(
mediaServersState,
key = { index: Int, server: Nip96MediaServers.ServerName ->
server.baseUrl
key = { index: Int, server: ServerName ->
keyType + server.baseUrl
},
) { index, entry ->
MediaServerEntry(
@@ -225,7 +280,7 @@ fun LazyListScope.renderMediaServerList(
item {
Spacer(modifier = StdVertSpacer)
MediaServerEditField {
MediaServerEditField(editLabel) {
onAddServer(it)
}
}
@@ -234,7 +289,7 @@ fun LazyListScope.renderMediaServerList(
@Composable
fun MediaServerEntry(
modifier: Modifier = Modifier,
serverEntry: Nip96MediaServers.ServerName,
serverEntry: ServerName,
isAmethystDefault: Boolean = false,
onAddOrDelete: (serverUrl: String) -> Unit,
) {
@@ -0,0 +1,131 @@
/**
* 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 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 BlossomServersViewModel : ViewModel() {
lateinit var account: Account
private val _fileServers = MutableStateFlow<List<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 {
ServerName(
URIReference.parse(serverUrl).host.value,
serverUrl,
ServerType.Blossom,
)
} catch (e: Exception) {
Log.d("MediaServersViewModel", "Invalid URL in Blossom 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 =
ServerName(
serverNameReference,
normalizedUrl,
ServerType.Blossom,
)
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(
ServerName(serverName, serverUrl, ServerType.Blossom),
)
}
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.sendBlossomServersList(serverList)
refresh()
}
}
}
private fun obtainFileServers(): List<String>? = account.getBlossomServersList()?.servers()
}
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.encoders.HttpUrlFormatter
@Composable
fun MediaServerEditField(
label: Int = R.string.add_a_nip96_server,
modifier: Modifier = Modifier,
onAddServer: (String) -> Unit,
) {
@@ -63,7 +64,7 @@ fun MediaServerEditField(
),
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.add_a_nip96_server)) },
label = { Text(text = stringRes(label)) },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
@@ -24,7 +24,6 @@ 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
@@ -32,10 +31,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.czeal.rfc3986.URIReference
class MediaServersViewModel : ViewModel() {
class NIP96ServersViewModel : ViewModel() {
lateinit var account: Account
private val _fileServers = MutableStateFlow<List<Nip96MediaServers.ServerName>>(emptyList())
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
val fileServers = _fileServers.asStateFlow()
private var isModified = false
@@ -50,11 +49,11 @@ class MediaServersViewModel : ViewModel() {
val obtainedFileServers = obtainFileServers() ?: emptyList()
obtainedFileServers.mapNotNull { serverUrl ->
try {
Nip96MediaServers
.ServerName(
URIReference.parse(serverUrl).host.value,
serverUrl,
)
ServerName(
URIReference.parse(serverUrl).host.value,
serverUrl,
ServerType.NIP96,
)
} catch (e: Exception) {
Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list")
null
@@ -82,7 +81,7 @@ class MediaServersViewModel : ViewModel() {
} catch (e: Exception) {
normalizedUrl
}
val serverRef = Nip96MediaServers.ServerName(serverNameReference, normalizedUrl)
val serverRef = ServerName(serverNameReference, normalizedUrl, ServerType.NIP96)
if (_fileServers.value.contains(serverRef)) {
return
} else {
@@ -101,7 +100,7 @@ class MediaServersViewModel : ViewModel() {
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
_fileServers.update {
it.minus(
Nip96MediaServers.ServerName(serverName, serverUrl),
ServerName(serverName, serverUrl, ServerType.NIP96),
)
}
isModified = true
@@ -0,0 +1,45 @@
/**
* 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
data class ServerName(
val name: String,
val baseUrl: String,
val type: ServerType = ServerType.NIP96,
)
enum class ServerType {
NIP96,
NIP95,
Blossom,
}
val DEFAULT_MEDIA_SERVERS: List<ServerName> =
listOf(
ServerName("Nostr.Build", "https://nostr.build", ServerType.NIP96),
ServerName("NostrCheck.me", "https://nostrcheck.me", ServerType.NIP96),
ServerName("NostPic", "https://nostpic.com", ServerType.NIP96),
ServerName("Sovbit", "https://files.sovbit.host", ServerType.NIP96),
ServerName("Void.cat", "https://void.cat", ServerType.NIP96),
ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom),
ServerName("NostrCheck.me (Blossom)", "https://cdn.nostrcheck.me", ServerType.Blossom),
ServerName("Primal", "https://blossom.primal.net", ServerType.Blossom),
)
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -42,14 +43,17 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.ammolite.relays.RelayStat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -66,6 +70,7 @@ fun AddDMRelayListDialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
SetDialogToEdgeToEdge()
Scaffold(
topBar = {
TopAppBar(
@@ -114,7 +119,7 @@ fun AddDMRelayListDialog(
),
verticalArrangement = Arrangement.SpaceAround,
) {
Explanation()
Explanation(postViewModel)
DMRelayList(postViewModel, accountViewModel, onClose, nav)
}
@@ -123,7 +128,7 @@ fun AddDMRelayListDialog(
}
@Composable
private fun Explanation() {
private fun Explanation(postViewModel: DMRelayListViewModel) {
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
@@ -133,8 +138,25 @@ private fun Explanation() {
Spacer(modifier = StdVertSpacer)
Text(
text = stringRes(id = R.string.dm_relays_not_found_examples),
text = stringRes(id = R.string.dm_relays_not_found_examples2),
)
Spacer(modifier = StdVertSpacer)
ResetDMRelaysLonger(postViewModel)
}
}
}
@Composable
fun ResetDMRelaysLonger(postViewModel: DMRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
DefaultDMRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringRes(R.string.default_relays_longer))
}
}
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -42,14 +43,17 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.ammolite.relays.RelayStat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -66,6 +70,7 @@ fun AddSearchRelayListDialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
SetDialogToEdgeToEdge()
Scaffold(
topBar = {
TopAppBar(
@@ -114,7 +119,7 @@ fun AddSearchRelayListDialog(
),
verticalArrangement = Arrangement.SpaceAround,
) {
Explanation()
Explanation(postViewModel)
SearchRelayList(postViewModel, accountViewModel, onClose, nav)
}
@@ -123,7 +128,7 @@ fun AddSearchRelayListDialog(
}
@Composable
private fun Explanation() {
private fun Explanation(postViewModel: SearchRelayListViewModel) {
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
@@ -135,6 +140,23 @@ private fun Explanation() {
Text(
text = stringRes(id = R.string.search_relays_not_found_examples),
)
Spacer(modifier = StdVertSpacer)
ResetSearchRelaysLonger(postViewModel)
}
}
}
@Composable
fun ResetSearchRelaysLonger(postViewModel: SearchRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
DefaultSearchRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringRes(R.string.default_relays_longer))
}
}
@@ -24,6 +24,7 @@ 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -49,11 +50,13 @@ 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.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer
@@ -171,91 +174,92 @@ fun MappedAllRelayListView(
)
},
) { pad ->
Column(
LazyColumn(
contentPadding = FeedPadding,
modifier =
Modifier
.fillMaxSize()
.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad),
) {
LazyColumn(contentPadding = FeedPadding) {
item {
SettingsCategory(
stringRes(R.string.public_home_section),
stringRes(R.string.public_home_section_explainer),
Modifier.padding(bottom = 8.dp),
)
}
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.public_notif_section),
stringRes(R.string.public_notif_section_explainer),
)
}
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.private_inbox_section),
stringRes(R.string.private_inbox_section_explainer),
action = {
ResetDMRelays(dmViewModel)
},
)
}
renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.private_outbox_section),
stringRes(R.string.private_outbox_section_explainer),
)
}
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.search_section),
stringRes(R.string.search_section_explainer),
action = {
ResetSearchRelays(searchViewModel)
},
)
}
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.local_section),
stringRes(R.string.local_section_explainer),
)
}
renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.kind_3_section),
stringRes(R.string.kind_3_section_description),
action = {
ResetKind3Relays(kind3ViewModel)
},
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd)
if (kind3Proposals.isNotEmpty()) {
item {
SettingsCategory(
stringRes(R.string.public_home_section),
stringRes(R.string.public_home_section_explainer),
Modifier.padding(bottom = 8.dp),
stringRes(R.string.kind_3_recommended_section),
stringRes(R.string.kind_3_recommended_section_description),
)
}
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.public_notif_section),
stringRes(R.string.public_notif_section_explainer),
)
}
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.private_inbox_section),
stringRes(R.string.private_inbox_section_explainer),
)
}
renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.private_outbox_section),
stringRes(R.string.private_outbox_section_explainer),
)
}
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.search_section),
stringRes(R.string.search_section_explainer),
action = {
ResetSearchRelays(searchViewModel)
},
)
}
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav)
item {
SettingsCategory(
stringRes(R.string.local_section),
stringRes(R.string.local_section_explainer),
)
}
renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav)
item {
SettingsCategoryWithButton(
stringRes(R.string.kind_3_section),
stringRes(R.string.kind_3_section_description),
action = {
ResetKind3Relays(kind3ViewModel)
},
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, 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, newNav)
}
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav)
}
}
}
@@ -280,7 +284,20 @@ fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
DefaultSearchRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringRes(R.string.default_relays))
}
}
@Composable
fun ResetDMRelays(postViewModel: DMRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
DefaultDMRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {
@@ -143,7 +143,7 @@ class Kind3RelayListViewModel : ViewModel() {
val proposed =
RelayListRecommendationProcessor
.reliableRelaySetFor(
account.liveKind3Follows.value.users.mapNotNull {
account.liveKind3Follows.value.authors.mapNotNull {
account.getNIP65RelayList(it)
},
relayUrlsToIgnore =
@@ -50,10 +50,10 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -104,6 +105,7 @@ fun RelayInformationDialog(
dismissOnClickOutside = false,
),
) {
SetDialogToEdgeToEdge()
Surface {
val color =
remember {
@@ -37,11 +37,9 @@ fun ClickableNoteTag(
accountViewModel: AccountViewModel,
nav: INav,
) {
val route = routeFor(baseNote, accountViewModel.userProfile())
ClickableText(
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
onClick = { nav.nav("Note/${baseNote.idHex}") },
onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
)
}
@@ -56,7 +56,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -33,7 +33,7 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
@@ -97,9 +97,7 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
val accountState =
accountViewModel.account.settings.showSensitiveContent
.collectAsStateWithLifecycle()
val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) }
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
@Composable
fun SetDialogToEdgeToEdge() {
val activityWindow = getActivityWindow()
val dialogWindow = (LocalView.current.parent as? DialogWindowProvider)?.window
val parentView = LocalView.current.parent as View
SideEffect {
if (activityWindow != null && dialogWindow != null) {
val attributes = WindowManager.LayoutParams()
attributes.copyFrom(activityWindow.attributes)
attributes.type = dialogWindow.attributes.type
dialogWindow.attributes = attributes
parentView.layoutParams =
FrameLayout.LayoutParams(
activityWindow.decorView.width,
activityWindow.decorView.height,
)
}
}
}
@@ -38,7 +38,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.stringRes
@@ -120,6 +120,7 @@ import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.Dimension
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -211,7 +212,7 @@ fun VideoView(
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
@@ -242,7 +243,7 @@ fun VideoView(
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
@@ -260,7 +261,7 @@ fun VideoView(
}
if (blurhash == null) {
val ratio = aspectRatio(dimensions)
val ratio = dimensions?.aspectRatio()
val modifier =
if (ratio != null && automaticallyStartPlayback.value) {
Modifier.aspectRatio(ratio)
@@ -294,7 +295,7 @@ fun VideoView(
}
}
} else {
val ratio = aspectRatio(dimensions)
val ratio = dimensions?.aspectRatio()
val modifier =
if (ratio != null) {
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.Window
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import com.vitorpamplona.amethyst.ui.navigation.getActivity
// Window utils
@Composable
fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
@Composable
fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow()
private tailrec fun Context.getActivityWindow(): Window? =
when (this) {
is Activity -> window
is ContextWrapper -> baseContext.getActivityWindow()
else -> null
}
@Composable
fun getActivity(): Activity? = LocalView.current.context.getActivity()
private tailrec fun Context.getActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
}
@@ -29,7 +29,6 @@ import android.widget.FrameLayout
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
@@ -67,8 +66,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@@ -104,8 +102,6 @@ fun ZoomableImageDialog(
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val orientation = LocalConfiguration.current.orientation
Dialog(
onDismissRequest = onDismiss,
properties =
@@ -115,7 +111,6 @@ fun ZoomableImageDialog(
),
) {
val view = LocalView.current
val insets = ViewCompat.getRootWindowInsets(view)
val orientation = LocalConfiguration.current.orientation
println("This Log only exists to force orientation listener $orientation")
@@ -151,7 +146,7 @@ fun ZoomableImageDialog(
}
@Composable
@OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalPermissionsApi::class)
private fun DialogContent(
allImages: ImmutableList<BaseMediaContent>,
imageUrl: BaseMediaContent,
@@ -296,7 +291,7 @@ private fun saveMediaToGallery(
localContext: Context,
accountViewModel: AccountViewModel,
) {
val isImage = content is MediaUrlImage && content is MediaLocalImage
val isImage = content is MediaUrlImage || content is MediaLocalImage
val success = if (isImage) R.string.image_saved_to_the_gallery else R.string.video_saved_to_the_gallery
val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video
@@ -329,8 +324,8 @@ private fun saveMediaToGallery(
onSuccess = {
accountViewModel.toast(success, success)
},
onError = {
accountViewModel.toast(failure, null, it)
onError = { innerIt ->
accountViewModel.toast(failure, null, innerIt)
},
)
}
@@ -338,7 +333,6 @@ private fun saveMediaToGallery(
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun InlineCarrousel(
allImages: ImmutableList<String>,
imageUrl: String,
@@ -385,7 +379,7 @@ private fun RenderImageOrVideo(
onToggleControllerVisibility: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
val automaticallyStartPlayback = remember { mutableStateOf(true) }
val contentScale =
if (isFiniteHeight) {
ContentScale.Fit
@@ -394,81 +388,41 @@ private fun RenderImageOrVideo(
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
if (content is MediaUrlImage) {
val mainModifier =
Modifier
.fillMaxWidth()
.zoomable(
rememberZoomState(),
onTap = {
if (onToggleControllerVisibility != null) {
onToggleControllerVisibility()
}
},
)
when (content) {
is MediaUrlImage -> {
val mainModifier =
Modifier
.fillMaxWidth()
.zoomable(
rememberZoomState(),
onTap = {
if (onToggleControllerVisibility != null) {
onToggleControllerVisibility()
}
},
)
UrlImageView(
content = content,
contentScale = contentScale,
mainImageModifier = mainModifier,
loadedImageModifier = Modifier.fillMaxWidth(),
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
)
} else if (content is MediaUrlVideo) {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
UrlImageView(
content = content,
contentScale = contentScale,
mainImageModifier = mainModifier,
loadedImageModifier = Modifier.fillMaxWidth(),
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
)
}
VideoViewInner(
videoUri = content.url,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
accountViewModel = accountViewModel,
)
} else if (content is MediaLocalImage) {
val mainModifier =
Modifier
.fillMaxWidth()
.zoomable(
rememberZoomState(),
onTap = {
if (onToggleControllerVisibility != null) {
onToggleControllerVisibility()
}
},
)
is MediaUrlVideo -> {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
LocalImageView(
content = content,
contentScale = contentScale,
mainImageModifier = mainModifier,
loadedImageModifier = Modifier.fillMaxWidth(),
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
)
} else if (content is MediaLocalVideo) {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
content.localFile?.let {
VideoViewInner(
videoUri = it.toUri().toString(),
videoUri = content.url,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
@@ -480,6 +434,54 @@ private fun RenderImageOrVideo(
accountViewModel = accountViewModel,
)
}
is MediaLocalImage -> {
val mainModifier =
Modifier
.fillMaxWidth()
.zoomable(
rememberZoomState(),
onTap = {
if (onToggleControllerVisibility != null) {
onToggleControllerVisibility()
}
},
)
LocalImageView(
content = content,
contentScale = contentScale,
mainImageModifier = mainModifier,
loadedImageModifier = Modifier.fillMaxWidth(),
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
)
}
is MediaLocalVideo -> {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
content.localFile?.let {
VideoViewInner(
videoUri = it.toUri().toString(),
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
accountViewModel = accountViewModel,
)
}
}
}
}
}
@@ -20,11 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.util.Log
import android.view.Window
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -44,6 +40,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -61,14 +58,13 @@ import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.net.toUri
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
@@ -79,7 +75,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.service.Blurhash
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
@@ -102,12 +98,11 @@ import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Dimension
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
@@ -311,7 +306,8 @@ fun LocalImageView(
contentScale = contentScale,
modifier = mainImageModifier,
) {
when (painter.state) {
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Loading,
-> {
if (content.blurhash != null) {
@@ -392,7 +388,7 @@ fun UrlImageView(
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
) {
val ratio = remember(content) { aspectRatio(content.dim) }
val ratio = content.dim?.aspectRatio()
val showImage =
remember {
@@ -409,7 +405,8 @@ fun UrlImageView(
contentScale = contentScale,
modifier = mainImageModifier,
) {
when (painter.state) {
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Loading,
-> {
if (content.blurhash != null) {
@@ -515,6 +512,8 @@ fun ImageUrlWithDownloadButton(
text = annotatedTermsString,
modifier = pressIndicator,
inlineContent = inlineContent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -554,44 +553,14 @@ fun ShowHash(content: MediaUrlContent) {
verifiedHash?.let { HashVerificationSymbol(it) }
}
fun aspectRatio(dim: String?): Float? {
fun aspectRatio(dim: Dimension?): Float? {
if (dim == null) return null
if (dim == "0x0") return null
val parts = dim.split("x")
if (parts.size != 2) return null
return try {
val width = parts[0].toFloat()
val height = parts[1].toFloat()
if (width < 0.1 || height < 0.1) {
null
} else {
width / height
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
return dim.width.toFloat() / dim.height.toFloat()
}
@Composable
private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
var cnt by remember { mutableStateOf<BaseMediaContent?>(null) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
delay(200)
cnt = content
}
}
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
}
@Composable
private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary
@@ -638,6 +607,8 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
text = annotatedTermsString,
modifier = pressIndicator,
inlineContent = inlineContent,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
@@ -662,17 +633,8 @@ fun DisplayBlurHash(
) {
if (blurhash == null) return
val context = LocalContext.current
val model =
remember {
BlurHashRequester.imageRequest(
context,
blurhash,
)
}
AsyncImage(
model = model,
model = Blurhash(blurhash),
contentDescription = description,
contentScale = contentScale,
modifier = modifier,
@@ -721,7 +683,7 @@ fun ShareImageAction(
videoUri: String?,
postNostrUri: String?,
blurhash: String?,
dim: String?,
dim: Dimension?,
hash: String?,
mimeType: String?,
onDismiss: () -> Unit,
@@ -771,7 +733,6 @@ fun ShareImageAction(
}
}
@OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null
@@ -820,25 +781,3 @@ private fun HashVerificationSymbol(verifiedHash: Boolean) {
}
}
}
// Window utils
@Composable
fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
@Composable fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow()
private tailrec fun Context.getActivityWindow(): Window? =
when (this) {
is Activity -> window
is ContextWrapper -> baseContext.getActivityWindow()
else -> null
}
@Composable fun getActivity(): Activity? = LocalView.current.context.getActivity()
private tailrec fun Context.getActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
}
@@ -85,7 +85,7 @@ class MarkdownMediaRenderer(
) {
if (canPreview) {
val content =
parser.parseMediaUrl(
parser.createMediaContent(
fullUrl = uri,
eventTags = tags ?: EmptyTagList,
description = title?.ifEmpty { null } ?: startOfText,
@@ -109,7 +109,7 @@ class MarkdownMediaRenderer(
uri: String,
richTextStringBuilder: RichTextString.Builder,
) {
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
val content = parser.createMediaContent(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
if (canPreview) {
if (content != null) {
@@ -67,7 +67,7 @@ open class DiscoverLiveFeedFilter(
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet =
account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users
account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors
val counter = ParticipantListBuilder()
val participantCounts =
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
@@ -33,7 +35,8 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class FilterByListParams(
val isGlobal: Boolean,
val isHiddenList: Boolean,
val followLists: Account.LiveFollowLists?,
val isAroundMe: Boolean,
val followLists: Account.LiveFollowList?,
val hiddenLists: Account.LiveHiddenUsers,
val now: Long = TimeUtils.oneMinuteFromNow(),
) {
@@ -43,24 +46,30 @@ class FilterByListParams(
fun isEventInList(noteEvent: Event): Boolean {
if (followLists == null) return false
if (isAroundMe && followLists.geotags.isEmpty() == true) return false
return if (noteEvent is LiveActivitiesEvent) {
noteEvent.participantsIntersect(followLists.users) ||
noteEvent.participantsIntersect(followLists.authors) ||
noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.communities)
noteEvent.isTaggedAddressableNotes(followLists.addresses)
} else if (noteEvent is CommentEvent) {
// ignore follows and checks only the root scope
noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.addresses)
} else {
noteEvent.pubKey in followLists.users ||
noteEvent.pubKey in followLists.authors ||
noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.communities)
noteEvent.isTaggedAddressableNotes(followLists.addresses)
}
}
fun isATagInList(aTag: ATag): Boolean {
if (followLists == null) return false
return aTag.pubKeyHex in followLists.users
return aTag.pubKeyHex in followLists.authors
}
fun match(
@@ -89,12 +98,13 @@ class FilterByListParams(
fun create(
userHex: String,
selectedListName: String,
followLists: Account.LiveFollowLists?,
followLists: Account.LiveFollowList?,
hiddenUsers: Account.LiveHiddenUsers,
): FilterByListParams =
FilterByListParams(
isGlobal = selectedListName == GLOBAL_FOLLOWS,
isHiddenList = showHiddenKey(selectedListName, userHex),
isAroundMe = selectedListName == AROUND_ME,
followLists = followLists,
hiddenLists = hiddenUsers,
)
@@ -64,6 +64,7 @@ class GeoHashFeedFilter(
it.event is AudioHeaderEvent
) &&
it.event?.isTaggedGeoHash(geoTag) == true &&
!it.isHiddenFor(account.flowHiddenUsers.value) &&
account.isAcceptable(it)
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
@@ -68,6 +68,7 @@ class HashtagFeedFilter(
it.event is AudioHeaderEvent
) &&
it.event?.isTaggedHash(hashTag) == true &&
!it.isHiddenFor(account.flowHiddenUsers.value) &&
account.isAcceptable(it)
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
@@ -75,6 +76,7 @@ class HomeConversationsFeedFilter(
it.event is TextNoteEvent ||
it.event is PollNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is CommentEvent ||
it.event is LiveActivitiesChatMessageEvent
) &&
filterParams.match(it.event) &&
@@ -26,8 +26,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
@@ -98,6 +100,8 @@ class HomeNewThreadFeedFilter(
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
noteEvent is PollNoteEvent ||
noteEvent is HighlightEvent ||
noteEvent is InteractiveStoryPrologueEvent ||
noteEvent is CommentEvent ||
noteEvent is AudioTrackEvent ||
noteEvent is AudioHeaderEvent
) &&
@@ -115,7 +115,7 @@ class NotificationFeedFilter(
it.event !is NIP90ContentDiscoveryRequestEvent &&
it.event !is GiftWrapEvent &&
(it.event is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) &&
(filterParams.isGlobal || filterParams.followLists?.authors?.contains(notifAuthor) == true) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
tagsAnEventByUser(it, loggedInUserHex)
@@ -27,19 +27,28 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.ThreadLevelCalculator
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableSet
@Immutable
class ThreadFeedFilter(
val account: Account,
val noteId: String,
private val noteId: String,
) : FeedFilter<Note>() {
override fun feedKey(): String = noteId
override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf()
val followingKeySet = account.liveKind3Follows.value.users
val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
val followingKeySet = account.liveKind3Follows.value.authors
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList()
// Filter out drafts made by other accounts on device
val filteredEvents =
eventsToWatch.allNotes
.filter { !it.isDraft() || (it.author?.pubkeyHex == account.userProfile().pubkeyHex) }
.toImmutableSet()
val filteredThreadInfo = ThreadAssembler.ThreadInfo(eventsToWatch.root, filteredEvents)
val eventsInHex = filteredThreadInfo.allNotes.map { it.idHex }.toSet()
val now = TimeUtils.now()
// Currently orders by date of each event, descending, at each level of the reply stack
@@ -56,6 +65,6 @@ class ThreadFeedFilter(
).signature
}
return eventsToWatch.sortedWith(order)
return filteredThreadInfo.allNotes.sortedWith(order)
}
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
@@ -73,6 +74,7 @@ class UserProfileNewThreadFeedFilter(
it.event is WikiNoteEvent ||
it.event is PollNoteEvent ||
it.event is HighlightEvent ||
it.event is InteractiveStoryPrologueEvent ||
it.event is AudioTrackEvent ||
it.event is AudioHeaderEvent ||
it.event is TorrentEvent
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isImageOrVideoUrl
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -28,6 +29,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PictureEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
@@ -66,10 +68,11 @@ class VideoFeedFilter(
val noteEvent = it.event
return (
(noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
(noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
(noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
(noteEvent is FileHeaderEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
(noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
(noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
noteEvent is PictureEvent
) &&
params.match(noteEvent) &&
(params.isHiddenList || account.isAcceptable(it))
@@ -29,6 +29,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -48,6 +49,8 @@ fun FeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LazyColumn(
contentPadding = FeedPadding,
state = listState,
@@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -52,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomListScreen
@@ -270,15 +275,51 @@ fun AppNavigation(
composable(
Route.NIP47Setup.route,
Route.NIP47Setup.arguments,
enterTransition = { slideInHorizontallyFromEnd },
enterTransition = { slideInVerticallyFromBottom },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
popExitTransition = { slideOutVerticallyToBottom },
) {
val nip47 = it.arguments?.getString("nip47")
NIP47SetupScreen(accountViewModel, nav, nip47)
}
composable(
Route.NewPost.route,
Route.NewPost.arguments,
enterTransition = { slideInVerticallyFromBottom },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutVerticallyToBottom },
) {
val draftMessage = it.arguments?.getString("message")?.ifBlank { null }
val attachment =
it.arguments?.getString("attachment")?.ifBlank { null }?.let {
Uri.parse(it)
}
val baseReplyTo = it.arguments?.getString("baseReplyTo")
val quote = it.arguments?.getString("quote")
val fork = it.arguments?.getString("fork")
val version = it.arguments?.getString("version")
val draft = it.arguments?.getString("draft")
val enableMessageInterface = it.arguments?.getBoolean("enableMessageInterface") ?: false
val enableGeolocation = it.arguments?.getBoolean("enableGeolocation") ?: false
NewPostScreen(
message = draftMessage,
attachment = attachment,
baseReplyTo = baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quote = quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
fork = fork?.let { hex -> accountViewModel.getNoteIfExists(hex) },
version = version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
enableMessageInterface = enableMessageInterface,
enableGeolocation = enableGeolocation,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -295,88 +336,129 @@ private fun NavigateIfIntentRequested(
accountStateViewModel: AccountStateViewModel,
) {
val activity = LocalContext.current.getActivity()
var newAccount by remember { mutableStateOf<String?>(null) }
var currentIntentNextPage by remember {
mutableStateOf(
activity.intent
?.data
?.toString()
?.ifBlank { null },
)
}
if (activity.intent.action == Intent.ACTION_SEND) {
// avoids restarting the new Post screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isBaseRoute(nav.controller, Route.NewPost.base)) return
currentIntentNextPage?.let { intentNextPage ->
var actionableNextPage by remember {
mutableStateOf(uriToRoute(intentNextPage))
// saves the intent to avoid processing again
var message by remember {
mutableStateOf(
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
it.ifBlank { null }
},
)
}
LaunchedEffect(intentNextPage) {
if (actionableNextPage != null) {
actionableNextPage?.let {
val currentRoute = getRouteWithArguments(nav.controller)
if (!isSameRoute(currentRoute, it)) {
nav.newStack(it)
}
actionableNextPage = null
}
} else if (intentNextPage.contains("ncryptsec1")) {
// login functions
Nip19Bech32.tryParseAndClean(intentNextPage)?.let {
newAccount = it
}
var media by remember {
mutableStateOf(
(activity.intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri),
)
}
actionableNextPage = null
} else {
accountViewModel.toast(
R.string.invalid_nip19_uri,
R.string.invalid_nip19_uri_description,
intentNextPage,
)
nav.newStack(buildNewPostRoute(draftMessage = message, attachment = media))
media = null
message = null
} else {
var newAccount by remember { mutableStateOf<String?>(null) }
var currentIntentNextPage by remember {
mutableStateOf(
activity.intent
?.data
?.toString()
?.ifBlank { null },
)
}
currentIntentNextPage?.let { intentNextPage ->
var actionableNextPage by remember {
mutableStateOf(uriToRoute(intentNextPage))
}
currentIntentNextPage = null
}
}
val scope = rememberCoroutineScope()
DisposableEffect(nav, activity) {
val consumer =
Consumer<Intent> { intent ->
val uri = intent.data?.toString()
if (!uri.isNullOrBlank()) {
// navigation functions
val newPage = uriToRoute(uri)
if (newPage != null) {
LaunchedEffect(intentNextPage) {
if (actionableNextPage != null) {
actionableNextPage?.let {
val currentRoute = getRouteWithArguments(nav.controller)
if (!isSameRoute(currentRoute, newPage)) {
nav.newStack(newPage)
if (!isSameRoute(currentRoute, it)) {
nav.newStack(it)
}
} else if (uri.contains("ncryptsec")) {
// login functions
Nip19Bech32.tryParseAndClean(uri)?.let {
newAccount = it
actionableNextPage = null
}
} else if (intentNextPage.contains("ncryptsec1")) {
// login functions
Nip19Bech32.tryParseAndClean(intentNextPage)?.let {
newAccount = it
}
actionableNextPage = null
} else {
accountViewModel.toast(
R.string.invalid_nip19_uri,
R.string.invalid_nip19_uri_description,
intentNextPage,
)
}
currentIntentNextPage = null
}
}
val scope = rememberCoroutineScope()
DisposableEffect(nav, activity) {
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
// avoids restarting the new Post screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (!isBaseRoute(nav.controller, Route.NewPost.base)) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
nav.newStack(buildNewPostRoute(draftMessage = it))
}
(intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
nav.newStack(buildNewPostRoute(attachment = it))
}
}
} else {
scope.launch {
delay(1000)
accountViewModel.toast(
R.string.invalid_nip19_uri,
R.string.invalid_nip19_uri_description,
uri,
)
val uri = intent.data?.toString()
if (!uri.isNullOrBlank()) {
// navigation functions
val newPage = uriToRoute(uri)
if (newPage != null) {
val currentRoute = getRouteWithArguments(nav.controller)
if (!isSameRoute(currentRoute, newPage)) {
nav.newStack(newPage)
}
} else if (uri.contains("ncryptsec")) {
// login functions
Nip19Bech32.tryParseAndClean(uri)?.let {
newAccount = it
}
} else {
scope.launch {
delay(1000)
accountViewModel.toast(
R.string.invalid_nip19_uri,
R.string.invalid_nip19_uri_description,
uri,
)
}
}
}
}
}
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
if (newAccount != null) {
AddAccountDialog(newAccount, accountStateViewModel) { newAccount = null }
if (newAccount != null) {
AddAccountDialog(newAccount, accountStateViewModel) { newAccount = null }
}
}
}
@@ -404,6 +486,9 @@ private fun isSameRoute(
return false
}
val slideInVerticallyFromBottom = slideInVertically(animationSpec = tween(), initialOffsetY = { it })
val slideOutVerticallyToBottom = slideOutVertically(animationSpec = tween(), targetOffsetY = { it })
val slideInHorizontallyFromEnd = slideInHorizontally(animationSpec = tween(), initialOffsetX = { it })
val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), targetOffsetX = { it })
@@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.CodeName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.FollowListState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -112,7 +112,7 @@ private fun LoggedInUserPictureDrawer(
fun FollowListWithRoutes(
followListsModel: FollowListState,
listName: String,
onChange: (CodeName) -> Unit,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
@@ -128,7 +128,7 @@ fun FollowListWithRoutes(
fun FollowListWithoutRoutes(
followListsModel: FollowListState,
listName: String,
onChange: (CodeName) -> Unit,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
@@ -82,7 +82,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -394,7 +394,7 @@ private fun FollowingAndFollowerCounts(
) {
Text(
text =
followingCount.value.users.size
followingCount.value.authors.size
.toString(),
fontWeight = FontWeight.Bold,
)
@@ -20,10 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
import android.Manifest
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -33,6 +35,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -41,12 +45,21 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.map
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.CodeName
import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
import com.vitorpamplona.amethyst.ui.screen.HashtagName
import com.vitorpamplona.amethyst.ui.screen.Name
@@ -55,15 +68,17 @@ import com.vitorpamplona.amethyst.ui.screen.ResourceName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun FeedFilterSpinner(
placeholderCode: String,
explainer: String,
options: ImmutableList<CodeName>,
options: ImmutableList<FeedDefinition>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -75,20 +90,85 @@ fun FeedFilterSpinner(
id = R.string.select_an_option,
)
var currentText by
var selected by
remember(placeholderCode, options) {
mutableStateOf(
options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption,
options.firstOrNull { it.code == placeholderCode },
)
}
val currentText by
remember(placeholderCode, options) {
derivedStateOf {
selected?.name?.name(context) ?: selectAnOption
}
}
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
LaunchedEffect(locationPermissionState.status.isGranted) {
Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted)
}
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Size20Modifier)
Text(currentText)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(currentText)
if (selected is AroundMeFeedDefinition) {
if (!locationPermissionState.status.isGranted) {
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
lineHeight = 12.sp,
)
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
.collectAsStateWithLifecycle()
when (val myLocation = location) {
is LocationState.LocationResult.Success -> {
LoadCityName(
geohashStr = myLocation.geoHash.toString(),
onLoading = {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
},
) { cityName ->
Text(
text = "($cityName)",
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
}
LocationState.LocationResult.LackPermission -> {
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
LocationState.LocationResult.Loading -> {
Text(
text = stringRes(R.string.loading_location),
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
}
}
}
}
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = explainer,
@@ -115,7 +195,7 @@ fun FeedFilterSpinner(
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
currentText = options[it].name.name(context)
selected = options[it]
optionsShowing = false
onSelect(it)
},
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import android.os.Bundle
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Immutable
@@ -39,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size25dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.net.URLEncoder
@Immutable
sealed class Route(
@@ -220,8 +222,35 @@ sealed class Route(
},
).toImmutableList(),
)
object NewPost :
Route(
route = "NewPost?message={message}&attachment={attachment}&baseReplyTo={baseReplyTo}&quote={quote}&fork={fork}&version={version}&draft={draft}&enableGeolocation={enableGeolocation}&enableMessageInterface={enableMessageInterface}",
icon = R.drawable.ic_moments,
arguments =
listOf(
navArgument("message") { type = NavType.StringType },
navArgument("attachment") { type = NavType.StringType },
navArgument("baseReplyTo") { type = NavType.StringType },
navArgument("quote") { type = NavType.StringType },
navArgument("fork") { type = NavType.StringType },
navArgument("version") { type = NavType.StringType },
navArgument("draft") { type = NavType.StringType },
navArgument("enableGeolocation") { type = NavType.BoolType },
navArgument("enableMessageInterface") { type = NavType.BoolType },
).toImmutableList(),
)
}
fun isBaseRoute(
navController: NavHostController,
startsWith: String,
): Boolean =
navController.currentBackStackEntry
?.destination
?.route
?.startsWith(startsWith) ?: false
fun getRouteWithArguments(navController: NavHostController): String? {
val currentEntry = navController.currentBackStackEntry ?: return null
return getRouteWithArguments(currentEntry.destination, currentEntry.arguments)
@@ -270,3 +299,25 @@ private fun getRouteWithArguments(
}
return route
}
fun buildNewPostRoute(
draftMessage: String? = null,
attachment: Uri? = null,
baseReplyTo: String? = null,
quote: String? = null,
fork: String? = null,
version: String? = null,
draft: String? = null,
enableGeolocation: Boolean = false,
enableMessageInterface: Boolean = false,
): String =
"NewPost?" +
"message=${draftMessage?.let { URLEncoder.encode(it, "utf-8") } ?: ""}&" +
"attachment=${attachment?.let { URLEncoder.encode(it.toString(), "utf-8") } ?: ""}&" +
"baseReplyTo=${baseReplyTo ?: ""}&" +
"quote=${quote ?: ""}&" +
"fork=${fork ?: ""}&" +
"version=${version ?: ""}&" +
"draft=${draft ?: ""}&" +
"enableGeolocation=$enableGeolocation&" +
"enableMessageInterface=$enableMessageInterface"
@@ -61,7 +61,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -671,13 +671,13 @@ fun LoadModerators(
val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value
?.users
?.authors
val allParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
@@ -724,7 +724,7 @@ private fun LoadParticipants(
val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value
?.users
?.authors
val allParticipants =
ParticipantListBuilder()
@@ -733,7 +733,7 @@ private fun LoadParticipants(
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants =
ParticipantListBuilder()
.followsThatParticipateOn(baseNote, allFollows)
@@ -882,7 +882,7 @@ fun RenderChannelThumb(
launch(Dispatchers.IO) {
val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value
?.users
?.authors
val allParticipants =
ParticipantListBuilder()
.followsThatParticipateOn(baseNote, followingKeySet)
@@ -890,7 +890,7 @@ fun RenderChannelThumb(
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList()
@@ -215,6 +215,7 @@ fun LoadCityName(
CachedGeoLocations
.geoLocate(geohashStr, geoHash.toLocation(), context)
?.ifBlank { null }
if (newCityName != null && newCityName != cityName) {
cityName = newCityName
}
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.note.types.EmptyState
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
@@ -100,6 +101,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight
import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent
@@ -157,6 +159,7 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DraftEvent
@@ -169,12 +172,14 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitRepositoryEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NIP90StatusEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PictureEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
@@ -428,7 +433,7 @@ fun ClickableNote(
content: @Composable () -> Unit,
) {
val updatedModifier =
remember(baseNote, backgroundColor.value) {
remember(baseNote, backgroundColor.value, modifier) {
modifier
.combinedClickable(
onClick = {
@@ -683,6 +688,19 @@ private fun RenderNoteRow(
nav,
)
}
is CommentEvent -> {
RenderTextEvent(
baseNote,
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
editState,
accountViewModel,
nav,
)
}
is NIP90ContentDiscoveryResponseEvent ->
RenderNIP90ContentDiscoveryResponse(
baseNote,
@@ -716,6 +734,8 @@ private fun RenderNoteRow(
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, false, accountViewModel)
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
is PictureEvent -> PictureDisplay(baseNote, true, false, backgroundColor, accountViewModel, nav)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, false, accountViewModel)
is CommunityPostApprovalEvent -> {
RenderPostApproval(
@@ -780,6 +800,17 @@ private fun RenderNoteRow(
nav,
)
is InteractiveStoryBaseEvent ->
RenderInteractiveStory(
baseNote,
makeItShort,
canPreview,
quotesLeft,
backgroundColor,
accountViewModel,
nav,
)
else -> {
RenderTextEvent(
baseNote,
@@ -85,10 +85,10 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
@@ -201,24 +201,16 @@ fun NoteQuickActionMenu(
accountViewModel: AccountViewModel,
nav: INav,
) {
val editDraftDialog = remember { mutableStateOf(false) }
if (editDraftDialog.value) {
NewPostView(
onClose = {
onDismiss()
editDraftDialog.value = false
},
accountViewModel = accountViewModel,
draft = note,
nav = EmptyNav,
)
}
NoteQuickActionMenu(
note = note,
onDismiss = onDismiss,
onWantsToEditDraft = { editDraftDialog.value = true },
onWantsToEditDraft = {
val route =
buildNewPostRoute(
draft = note.idHex,
)
nav.nav(route)
},
accountViewModel = accountViewModel,
nav = nav,
)
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -31,6 +32,7 @@ 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -42,7 +44,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -63,37 +64,172 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.StringToastMsg
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockVitorAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BigPadding
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
@Preview
@Composable
fun PollNotePreview() {
val event =
PollNoteEvent(
id = "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72",
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
createdAt = 1729186078,
content = "Testing polls again",
sig = "540101837a8826e2ae28401ee5f4fd8606def8501bec92a74a9e05264bb2c67558b927edf23bb085f5b5f0d91a61c65a25c37a3b92075bf10c9be03dbbe8e94e",
tags =
arrayOf(
arrayOf("poll_option", "0", "OP1"),
arrayOf("poll_option", "1", "OP2"),
arrayOf("poll_option", "2", "OP3"),
arrayOf("value_maximum", "2"),
arrayOf("value_minimum", "2"),
arrayOf("alt", "Poll event"),
),
)
val zapVote =
LnZapEvent(
id = "2a17fdcd0e387d1623c7313d7aa2848e18dde8a942cfe8a2d6b686ea5f68f01a",
pubKey = "79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432",
createdAt = 1729186293,
content = "Testing polls again",
sig = "819cbd8daccd173bc411f71deb4dc0fd7d281797f7f0318d2fbc6c5076e4ebe0aa52c16fd113ca5fcc3ee133cd0c4a11c6d3f77e4fa957d5401da1aff07028d0",
tags =
arrayOf(
arrayOf("p", "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a"),
arrayOf("e", "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72"),
arrayOf("P", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"),
arrayOf("bolt11", "lnbc20n1pn3zj05dqqnp4qtfc238rdkzsj26waa3l8zgag9damzltzsqcrlscj9gvpc7ch2qs2pp5nz0dn8hg5d8z5z9cgd3x8uhnv68hkgg03v3phqvdm26r0trcm27ssp5qxgfplfnfl9q6lh96stly8t90zgkyy4emlgdhadgp8n60eh8ghzs9qyysgqcqpcxqyz5vqrzjqvdnqyc82a9maxu6c7mee0shqr33u4z9z04wpdwhf96gxzpln8jcrapyqqqqqqp2rcqqqqlgqqqqqzsq2qrzjqw9fu4j39mycmg440ztkraa03u5qhtuc5zfgydsv6ml38qd4azymlapyqqqqqqqp9sqqqqlgqqqq86qqjqrzjq26922n6s5n5undqrf78rjjhgpcczafws45tx8237y7pzx3fg8wwxrgayyqq2mgqqqqqqqqqqqqqqqqq2qzkp9q0nyx5508kumhsa8c5x82c96nuccvszlmann6mzf7qvjagwsmmft3acvvqz5q92uf5et4yr53zcgmvphtg5xephcpe5lqle26kcp530mr3"),
arrayOf("preimage", "a2ea9951a3ced7bc9d6099ce28a12f027bb1c2116106a6baf7001122c3e08891"),
arrayOf("description", "{\"id\":\"c37b692b2bb23cd543b7643962d336fe3ae96a5330769ea047808b20f80b0c6f\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1729186292,\"kind\":9734,\"tags\":[[\"e\",\"6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72\"],[\"p\",\"f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a\"],[\"relays\",\"wss://nos.lol/\",\"wss://vitor.nostr1.com/\"],[\"alt\",\"Zap request\"],[\"poll_option\",\"0\"]],\"content\":\"\",\"sig\":\"83f2243e27d804bad13443937ddf71de7c049e85f71264eaf3a1b751a04844a65ec4bb5916722a1cdf9248f46dc5e03a92be9de543f03d022fb3e7ea08d1cf00\"}"),
),
)
val accountViewModel = mockVitorAccountViewModel()
val nav = EmptyNav
val baseNote: Note?
runBlocking {
withContext(Dispatchers.IO) {
LocalCache.justConsume(event, null)
LocalCache.consume(zapVote.zapRequest!!)
LocalCache.consume(zapVote, null)
baseNote = LocalCache.getOrCreateNote("6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72")
}
}
val color = MaterialTheme.colorScheme.background
if (baseNote != null) {
ThemeComparisonColumn(
toPreview = {
Column(
Modifier.padding(10.dp),
) {
PollNote(
baseNote = baseNote,
true,
remember { mutableStateOf(color) },
accountViewModel,
nav,
)
}
},
)
}
}
@Preview
@Composable
fun PollNotePreview2() {
val event =
PollNoteEvent(
id = "3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab",
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
createdAt = 1729191389,
content = "Test",
sig = "fc7f83e4dc84082a9a473338d9df2fdb876c59fbc94d49fdbce1e0bfd85cc7d50b14d4f0d08e8a84ef46f99a4e889e261cc898e2caeca4a153d8925254c5a761",
tags =
arrayOf(
arrayOf("poll_option", "0", "Pesquisa em portugues"),
arrayOf("poll_option", "1", "Pesquisa em ingles"),
arrayOf("value_maximum", "2"),
arrayOf("value_minimum", "2"),
arrayOf("alt", "Poll event"),
),
)
val accountViewModel = mockAccountViewModel()
val nav = EmptyNav
val baseNote: Note?
runBlocking {
withContext(Dispatchers.IO) {
LocalCache.justConsume(event, null)
baseNote = LocalCache.getOrCreateNote("3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab")
}
}
val color = MaterialTheme.colorScheme.background
if (baseNote != null) {
ThemeComparisonColumn(
toPreview = {
Column(
Modifier.padding(10.dp),
) {
PollNote(
baseNote = baseNote,
true,
remember { mutableStateOf(color) },
accountViewModel,
nav,
)
}
},
)
}
}
@Composable
fun PollNote(
baseNote: Note,
@@ -202,6 +338,7 @@ private fun OptionNote(
nonClickablePrepend = {},
clickablePrepend = {
RenderOptionBeforeVote(
baseNote,
poolOption.descriptor,
canPreview,
tags,
@@ -238,13 +375,7 @@ private fun RenderOptionAfterVote(
QuoteBorder,
),
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = {
poolOption.tally.value.toFloat()
},
)
DisplayProgress(poolOption, color, modifier = Modifier.matchParentSize())
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -258,10 +389,7 @@ private fun RenderOptionAfterVote(
.width(45.dp)
},
) {
Text(
text = "${(poolOption.tally.value.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold,
)
TallyText(poolOption)
}
Column(
@@ -289,8 +417,46 @@ private fun RenderOptionAfterVote(
}
}
@Composable
fun DisplayProgress(
poolOption: PollOption,
color: Color,
modifier: Modifier,
) {
val progress by poolOption.tally
// The LinearProgressIndicator has some weird update issues and renders inaccurate percentages.
Box(modifier = modifier) {
Box(
modifier =
Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(color = color),
) {
}
}
}
@Composable
private fun TallyText(poolOption: PollOption) {
val state = poolOption.tally
val progressTxt by
remember {
derivedStateOf {
"${(state.value * 100).roundToInt()}%"
}
}
Text(
text = progressTxt,
fontWeight = FontWeight.Bold,
)
}
@Composable
private fun RenderOptionBeforeVote(
baseNote: Note,
description: String,
canPreview: Boolean,
tags: ImmutableListOfLists<String>,
@@ -308,17 +474,19 @@ private fun RenderOptionBeforeVote(
QuoteBorder,
),
) {
TranslatableRichTextViewer(
content = description,
canPreview = canPreview,
quotesLeft = 1,
modifier = BigPadding,
tags = tags,
backgroundColor = backgroundColor,
id = description,
accountViewModel = accountViewModel,
nav = nav,
)
Column(BigPadding) {
TranslatableRichTextViewer(
content = description,
canPreview = canPreview,
quotesLeft = 1,
modifier = Modifier,
tags = tags,
backgroundColor = backgroundColor,
id = baseNote.idHex + description,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -382,16 +550,12 @@ fun ZapVote(
)
return@combinedClickable
} else if (
accountViewModel.account.settings.zapAmountChoices.value.size == 1 &&
pollViewModel.isValidInputVoteAmount(
accountViewModel.account.settings.zapAmountChoices.value
.first(),
)
accountViewModel.zapAmountChoices().size == 1 &&
pollViewModel.isValidInputVoteAmount(accountViewModel.zapAmountChoices().first())
) {
accountViewModel.zap(
baseNote,
accountViewModel.account.settings.zapAmountChoices.value
.first() * 1000,
accountViewModel.zapAmountChoices().first() * 1000,
poolOption.option,
"",
context,
@@ -524,7 +688,7 @@ fun FilteredZapAmountChoicePopup(
val context = LocalContext.current
// TODO: Move this to the viewModel
val zapPaymentChoices by accountViewModel.account.settings.zapAmountChoices
val zapPaymentChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
val zapMessage = ""

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