Compare commits

...

554 Commits

Author SHA1 Message Date
Vitor Pamplona
5225c6ad07 v0.82.3 2023-12-13 18:57:16 -05:00
Vitor Pamplona
c7be0e4a95 Memory improvements on relay lists and LiveData objects 2023-12-13 18:51:30 -05:00
Vitor Pamplona
d4f060d509 Improvements to the rendering of chat headers 2023-12-13 18:50:36 -05:00
Vitor Pamplona
9ba9ddee50 Using the new ChatHeader layout 2023-12-13 15:23:46 -05:00
Vitor Pamplona
7bbdd5e877 Avoid remembering Modifier 2023-12-13 15:23:32 -05:00
Vitor Pamplona
6cc65ce84a Avoid remembering Modifier 2023-12-13 15:22:23 -05:00
Vitor Pamplona
6c711f8acc v0.82.2 2023-12-13 12:10:06 -05:00
Vitor Pamplona
acefff80ee - Fixes the transition between short preview and full text on markdown.
- Adds NIP-44 metatags to markdown rendering.
- Adds background video rendering on markdown
- Refactors a few class names.
2023-12-13 12:09:59 -05:00
Vitor Pamplona
0e5b5d657f Calculate the text width of a space outside a composable. 2023-12-12 20:31:03 -05:00
Vitor Pamplona
c2532322fd Fixing the image link classification with uppercase extensions. 2023-12-12 20:29:28 -05:00
Vitor Pamplona
dc73aec334 No need to remember this. 2023-12-12 18:57:14 -05:00
Vitor Pamplona
aa909184fd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-12 18:09:27 -05:00
Vitor Pamplona
205dd75e7e Update README.md
Aligning screenshots
2023-12-12 18:09:18 -05:00
Vitor Pamplona
be9100dc5e Minor refactoring. 2023-12-12 18:00:02 -05:00
Vitor Pamplona
b2e35cdb3e v0.82.1 2023-12-12 11:20:33 -05:00
Vitor Pamplona
827e168bf2 Fixing minification issue on release. 2023-12-12 11:16:46 -05:00
Vitor Pamplona
a14d4e6f4e v0.82.0 2023-12-12 09:32:59 -05:00
Vitor Pamplona
de61c0350e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-12 09:31:25 -05:00
Vitor Pamplona
3e3c97076d Formatting 2023-12-12 09:31:14 -05:00
Vitor Pamplona
f62c986016 Improving sizes of blurhashes 2023-12-12 08:50:11 -05:00
Vitor Pamplona
04108e893d Merge pull request #720 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-12 08:44:58 -05:00
Crowdin Bot
042394796a New Crowdin translations by GitHub Action 2023-12-12 13:44:16 +00:00
Vitor Pamplona
bfce57f686 Improving the video switching from blurhash to video 2023-12-11 19:58:29 -05:00
Vitor Pamplona
5bb983ba5a Adding blurhash when images are not supposed to be downloaded. 2023-12-11 17:57:19 -05:00
Vitor Pamplona
debae02421 Improves the rendering of inline metadata 2023-12-11 16:22:01 -05:00
Vitor Pamplona
6fe93fdf33 Reverting image comment 2023-12-11 14:26:28 -05:00
Vitor Pamplona
3d225423ef Adds support for nip54 2023-12-11 12:51:53 -05:00
Vitor Pamplona
49e1da8812 Spacing 2023-12-11 12:50:44 -05:00
Vitor Pamplona
04addc4cba Adds support for the FileServers kind 2023-12-10 21:27:47 -05:00
Vitor Pamplona
c5cf2c5442 Adding the delete option for NIP-96 2023-12-10 21:21:00 -05:00
Vitor Pamplona
ddb7e6b7f2 Updates JNA 2023-12-10 18:32:47 -05:00
Vitor Pamplona
e5af34efca Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-08 22:08:30 -05:00
Vitor Pamplona
b3bdbbed98 - Migrates the old imageservers to NIP-96 for the main feed, chats, private messages, stories and user metadata uploads.
- Fixes hash calculation of the entire payload.
- Unifies uploads into NIP-94 images.
2023-12-08 22:08:19 -05:00
Vitor Pamplona
656b066d3a Merge pull request #719 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-08 19:50:21 -05:00
Crowdin Bot
4248fadc69 New Crowdin translations by GitHub Action 2023-12-09 00:45:17 +00:00
Vitor Pamplona
18281cbc49 Merge pull request #718 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-08 19:44:05 -05:00
Crowdin Bot
1d67af328a New Crowdin translations by GitHub Action 2023-12-08 16:23:27 +00:00
Vitor Pamplona
72b4f6acef Merge pull request #715 from davotoula/tr
Correct spelling of localised string reference
2023-12-08 11:21:52 -05:00
Vitor Pamplona
26273270ba Merge branch 'main' into tr 2023-12-08 11:21:46 -05:00
Vitor Pamplona
db3824c3c7 New Cashu Redeeming card. 2023-12-06 18:43:54 -05:00
Vitor Pamplona
d6de1d03d8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-06 15:40:05 -05:00
Vitor Pamplona
8e4b7c66b5 Adds a UserAgent to all HTTP requests. 2023-12-06 15:40:01 -05:00
Vitor Pamplona
45c750069a Refactoring the update of channel information. 2023-12-06 15:38:31 -05:00
Vitor Pamplona
a411a72024 Merge pull request #716 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-06 14:59:12 -05:00
Crowdin Bot
219d163680 New Crowdin translations by GitHub Action 2023-12-06 19:38:31 +00:00
Vitor Pamplona
a4c9f42205 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-06 14:36:21 -05:00
Vitor Pamplona
a8936c54d8 Adds a Marketplace tab to Discovery 2023-12-06 13:40:54 -05:00
David Kaspar
74875bb564 Correct spelling of localised string reference 2023-12-06 15:07:52 +01:00
Vitor Pamplona
61d412d941 Merge pull request #714 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-06 08:14:16 -05:00
Crowdin Bot
5bb1e47edf New Crowdin translations by GitHub Action 2023-12-06 13:13:27 +00:00
Vitor Pamplona
b023614b5f Merge pull request #713 from davotoula/tr
Updated translations for cs/de/sv/pt
2023-12-06 08:11:39 -05:00
David Kaspar
7245718003 Updated translations for cs/de/sv/pt 2023-12-06 12:15:02 +01:00
David Kaspar
ba1f98d930 Updated translations for cs/de/sv/pt 2023-12-06 12:08:20 +01:00
David Kaspar
ceeabffcb7 Correct spelling of localised string reference 2023-12-06 12:04:50 +01:00
David Kaspar
2a943493b3 Updated translations for cs/de/sv/pt 2023-12-06 11:21:41 +01:00
Vitor Pamplona
332a2f41b6 Moves the relay observer and reconnection to Service Manager's scope. 2023-12-05 21:48:43 -05:00
Vitor Pamplona
ce2cfb7c86 fixes bug when updating relay list that used keep the previous list. 2023-12-05 21:48:05 -05:00
Vitor Pamplona
41ef1abf0e Fixes deletion of replaceable events. 2023-12-05 21:47:20 -05:00
Vitor Pamplona
fce27c3c55 Presenting better error messages when the image upload fails. 2023-12-05 21:46:35 -05:00
Vitor Pamplona
6662966ab3 Fixing crash when image is an SVG and tries to compress. 2023-12-05 21:45:56 -05:00
Vitor Pamplona
f458f00edc 1. Adds Classified creation from Amethyst.
2. Adds relay information for Replaceable events.
2023-12-05 21:44:49 -05:00
Vitor Pamplona
8f1fbe10e9 Merge pull request #712 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-05 11:41:45 -05:00
Crowdin Bot
69efa78fbc New Crowdin translations by GitHub Action 2023-12-05 16:16:54 +00:00
Vitor Pamplona
64a07d8760 Updates to fix Crash due to malformed video formats. 2023-12-05 09:52:15 -05:00
Vitor Pamplona
cb672d92d0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-04 13:25:54 -05:00
Vitor Pamplona
95bcbfdded Reusing common icons for Cashu's Redemption 2023-12-04 13:23:19 -05:00
Vitor Pamplona
ac269727bc Merge pull request #711 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-04 11:57:45 -05:00
Crowdin Bot
b1a7d18c0a New Crowdin translations by GitHub Action 2023-12-04 15:53:21 +00:00
Vitor Pamplona
619b59357b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-04 10:52:23 -05:00
Vitor Pamplona
86dcc3ccd9 Merge pull request #710 from believethehype/main
add a button to cashu preview to redeem on external wallet
2023-12-04 10:51:41 -05:00
Vitor Pamplona
6772360ae7 Fixes bottom bar appearing in chats when the keyboard is open. 2023-12-04 10:48:29 -05:00
Vitor Pamplona
05cbbd79cb Uses nostr.wine instead of filter.nostr.wine as a search relay. 2023-12-04 10:47:38 -05:00
Believethehype
a80c03a2d2 Update CashuRedeem.kt 2023-12-04 16:20:40 +01:00
Believethehype
6c3abe17ed add a button to cashu preview to redeem on external wallet, additional to melting to ln 2023-12-03 15:02:22 +01:00
Vitor Pamplona
7cd09261de Optimizes the rendering of the side bar. 2023-12-02 17:23:15 -05:00
Vitor Pamplona
6ef1bee8a0 Updates EOSE in the same thread of the new event to reduce the amount of coroutine launches. 2023-12-02 14:54:34 -05:00
Vitor Pamplona
66b8b04db3 Updates dependencies. 2023-12-02 14:14:15 -05:00
Vitor Pamplona
3b06abb2bc Upgrades junit benchmark 2023-12-02 14:12:10 -05:00
Vitor Pamplona
138fafa204 Updates gradle 2023-12-02 14:11:19 -05:00
Vitor Pamplona
9cbdbb8592 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-02 13:56:53 -05:00
Vitor Pamplona
1c0ec93cbb Testing the use of just one HttpClient for the entire app. 2023-12-02 13:55:55 -05:00
Vitor Pamplona
c6fa2b5f80 Merge pull request #708 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-02 13:46:20 -05:00
Crowdin Bot
dd55ad18e3 New Crowdin translations by GitHub Action 2023-12-02 18:39:06 +00:00
Vitor Pamplona
284da365a1 Merge pull request #707 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-02 13:37:59 -05:00
Crowdin Bot
7a06c2e9c4 New Crowdin translations by GitHub Action 2023-12-02 16:21:46 +00:00
Vitor Pamplona
61449774a1 Improves the Redeeming feedback
Improves fee calculation from the mint
2023-12-02 11:18:01 -05:00
Vitor Pamplona
81a3baa41c Merge pull request #706 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-01 09:35:18 -05:00
Crowdin Bot
4b7e58dd1c New Crowdin translations by GitHub Action 2023-12-01 14:06:41 +00:00
Vitor Pamplona
14ce42401b Merge pull request #705 from greenart7c3/main
fix zap splits when using amber with intents
2023-12-01 09:05:26 -05:00
greenart7c3
ecb8344287 fix zap splits when using amber with intents 2023-12-01 10:52:15 -03:00
Vitor Pamplona
9bc5549d76 v0.81.5 2023-11-30 17:28:58 -05:00
Vitor Pamplona
8ac8894ccd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-30 17:27:03 -05:00
Vitor Pamplona
efb9814f1b Fixes blocked lists on Videos. 2023-11-30 17:18:41 -05:00
Vitor Pamplona
3582f587b3 Merge pull request #704 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-30 17:05:19 -05:00
Crowdin Bot
4fbc9f6d53 New Crowdin translations by GitHub Action 2023-11-30 21:26:37 +00:00
Vitor Pamplona
e9ec1c45a0 v0.81.4 2023-11-30 16:25:07 -05:00
Vitor Pamplona
b2b7cba352 Loads basic information for all the accounts in the app. 2023-11-30 16:24:53 -05:00
Vitor Pamplona
393fbd0359 clean up 2023-11-30 15:17:21 -05:00
Vitor Pamplona
47ae42e3fe Avoiding bug when creating accounts in the background and trying to create the live set 2023-11-30 15:16:21 -05:00
Vitor Pamplona
5db3ede09e Makes Client thread safe. Forcing the disconnect of an old relay list before connecting to a new one. 2023-11-30 15:03:58 -05:00
Vitor Pamplona
eca420d789 Fixing the scope in HomeDataSource. 2023-11-30 15:02:16 -05:00
Vitor Pamplona
5347373cde Avoids sending filters with empty lists. 2023-11-30 14:53:59 -05:00
Vitor Pamplona
05596cb9bf Forces websocket closure onFailure 2023-11-30 14:51:55 -05:00
Vitor Pamplona
3d1310295b No need to call two launch functions 2023-11-30 14:51:13 -05:00
Vitor Pamplona
35c780f81b Avoids bug on creating the live set in the IO thread. 2023-11-30 14:50:24 -05:00
Vitor Pamplona
b021920eaa Emitting an empty list if it cannot decrypt it 2023-11-30 11:22:42 -05:00
Vitor Pamplona
2b27ac3aec Cache zap calculations in notification cards. 2023-11-30 11:22:06 -05:00
Vitor Pamplona
c692c9125b Fixes null list names showing before the list is loaded 2023-11-30 10:33:48 -05:00
Vitor Pamplona
7322fd7aae Fixes hidden buttons in the Chat creation screen. 2023-11-30 10:30:12 -05:00
Vitor Pamplona
07a92cd70d Fixes all or nothing decryption of Zaps failing in a few cases. Now it has a fallback when decryption fails. 2023-11-30 10:15:36 -05:00
Vitor Pamplona
21a797be9d v0.81.3 2023-11-29 17:34:15 -05:00
Vitor Pamplona
fe49cbabce Improves the rendering of LN Invoice Previews. 2023-11-29 17:22:04 -05:00
Vitor Pamplona
9f674a0088 Adds flexibility on timeouts between WIFI and mobile connections. 2023-11-29 17:21:40 -05:00
Vitor Pamplona
b44e94cb16 Adds a test case for urls ending with a period. 2023-11-29 16:50:36 -05:00
Vitor Pamplona
a8da645be3 Increasing timeout to account for slower connections. 2023-11-29 16:50:11 -05:00
Vitor Pamplona
c6e6bf6032 Loads the user profile before building the live set. 2023-11-29 16:14:02 -05:00
Vitor Pamplona
7596f2a43e v0.81.2 2023-11-29 15:13:19 -05:00
Vitor Pamplona
6b7bb209d4 Refinements to ShopStr's event rendering. 2023-11-29 14:56:42 -05:00
Vitor Pamplona
fcdb863c2b Merge pull request #701 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-29 10:54:19 -05:00
Crowdin Bot
4e5a9f7366 New Crowdin translations by GitHub Action 2023-11-29 15:52:07 +00:00
Vitor Pamplona
758f3636f3 Merge pull request #700 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-29 10:50:54 -05:00
Vitor Pamplona
7dafb8317a version 0.81.1 2023-11-29 10:50:22 -05:00
Crowdin Bot
21a5827719 New Crowdin translations by GitHub Action 2023-11-29 15:40:53 +00:00
Vitor Pamplona
b9486eb3e6 Adds back button to the top of the Nav bar of the Thread view. 2023-11-29 10:38:31 -05:00
Vitor Pamplona
dbbfaae02c Fixes video release coroutine being killed by Android 2023-11-29 10:17:57 -05:00
Vitor Pamplona
44b25b7c7c Refines the EOSE strategy to make sure relays that have not replied have a chance to 2023-11-29 10:06:28 -05:00
Vitor Pamplona
0dabf3f2d3 Scopes the service manager into the AccountState ViewModel 2023-11-29 09:14:04 -05:00
Vitor Pamplona
a85ea70e4f Avoids requesting an empty list of chats 2023-11-28 22:23:34 -05:00
Vitor Pamplona
a02519b021 Forces close to happen in an IO thread 2023-11-28 22:22:28 -05:00
Vitor Pamplona
04e70a7886 Make the afterEOSE logic subscription dependent. 2023-11-28 22:22:08 -05:00
Vitor Pamplona
3bfaef2b45 Using the entire event id to filter threads. 2023-11-28 22:17:24 -05:00
Vitor Pamplona
e992e72f4d Making sure the service manager stops the relay subscriptions before disconnecting. 2023-11-28 22:17:01 -05:00
Vitor Pamplona
1c545ee33a Forces the closure. The method close wasn't closing all connections. 2023-11-28 18:00:44 -05:00
Vitor Pamplona
ab93a39ad6 Avoids race conditions when the data source is supposed to have stopped 2023-11-28 18:00:22 -05:00
Vitor Pamplona
7f25e1c434 Limits Notification downloads to 1 week. 2023-11-28 17:59:28 -05:00
Vitor Pamplona
67b7bd3258 Protection against NPEs 2023-11-28 16:36:27 -05:00
Vitor Pamplona
e2aaa8efe0 Migrates from PAY to NOTIFY as per https://github.com/nostr-protocol/nips/pull/901 2023-11-28 16:07:24 -05:00
Vitor Pamplona
5356d869fd Reduces the amount of wasted memory by using Array<Array<List>> as opposed to List<List<String>> for tags. 2023-11-28 15:00:41 -05:00
Vitor Pamplona
d87542a949 Reduces JC usage by creating arrays with the exact amount of bytes when deserializing events. 2023-11-28 09:37:00 -05:00
Vitor Pamplona
98d29ac6dc v0.81.0 2023-11-28 09:05:25 -05:00
Vitor Pamplona
532048c35e Merge pull request #699 from greenart7c3/main
fix crash tryng to decrypt/parse gossip
2023-11-27 12:49:15 -05:00
greenart7c3
386960dc62 fix crash tryng to decrypt/parse gossip 2023-11-27 14:28:15 -03:00
Vitor Pamplona
ae3cc5da2a Fixing benchmark tests after the refactoring. 2023-11-26 21:10:15 -05:00
Vitor Pamplona
34c2ba4922 Typo 2023-11-26 18:30:29 -05:00
Vitor Pamplona
074a7d41fd Adding payment requests from nostr.wine 2023-11-26 18:00:08 -05:00
Vitor Pamplona
7d4fbc3e04 Reducing the precision to load images. 2023-11-26 17:59:10 -05:00
Vitor Pamplona
a2d38381b1 Runs the translation as a UI Scope instead of ViewModel's 2023-11-26 15:02:10 -05:00
Vitor Pamplona
19fb129f7c Fixing test cases after the mega refactoring 2023-11-26 14:59:00 -05:00
Vitor Pamplona
5bfc53136b updates jackson. 2023-11-25 19:49:51 -05:00
Vitor Pamplona
1677606b15 updating via AGP 2023-11-25 19:46:49 -05:00
Vitor Pamplona
8c10173b68 Merge pull request #698 from greenart7c3/main
fix wrong pubkey in encrypt/decrypt nip44 with external signer
2023-11-25 16:31:42 -05:00
greenart7c3
285ae056a2 fix wrong pubkey in encrypt and decrypt nip44 external signer 2023-11-25 14:28:48 -03:00
Vitor Pamplona
8cdd3be085 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-24 12:28:01 -05:00
Vitor Pamplona
8f586780a1 Slightly improving performance 2023-11-24 12:27:52 -05:00
Vitor Pamplona
dc3c730e3b adding some labels to crossfades 2023-11-24 11:54:35 -05:00
Vitor Pamplona
df823102da Typo 2023-11-24 11:53:51 -05:00
Vitor Pamplona
1ab857d72b Merge pull request #697 from greenart7c3/main
support for sending/receiving multiple events from external signer
2023-11-24 10:41:06 -05:00
Vitor Pamplona
78863b26d0 Merge pull request #696 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-24 10:40:16 -05:00
Crowdin Bot
0f74bc7b74 New Crowdin translations by GitHub Action 2023-11-24 15:37:59 +00:00
Vitor Pamplona
e7893cacd5 Updating coil 2023-11-24 10:35:02 -05:00
greenart7c3
8a18781048 support for sending/receiving multiple events from external signer 2023-11-24 12:33:10 -03:00
Vitor Pamplona
ad52855f5a Merge pull request #695 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-23 21:59:47 -05:00
Crowdin Bot
50fbde17df New Crowdin translations by GitHub Action 2023-11-24 02:29:22 +00:00
Vitor Pamplona
c6d47ca9c6 Merge pull request #694 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-23 21:26:55 -05:00
Crowdin Bot
072b14bdda New Crowdin translations by GitHub Action 2023-11-23 15:49:32 +00:00
Vitor Pamplona
70964e680a Migrates to MuteList 10000 2023-11-23 10:46:56 -05:00
Vitor Pamplona
954330064d Trying to save from crashes on the signer 2023-11-22 17:04:57 -05:00
Vitor Pamplona
3666a0b980 Fixes race conditions when opening videos. 2023-11-22 16:29:05 -05:00
Vitor Pamplona
61f52ef62e Trimming memory onStop 2023-11-22 16:10:55 -05:00
Vitor Pamplona
74d66f2035 Fixes logging for new events. 2023-11-22 14:39:01 -05:00
Vitor Pamplona
0de4cd5c18 Adding a geohash mipmap to events. 2023-11-22 12:48:19 -05:00
Vitor Pamplona
d1bef11f88 Fixes spacing when drawing POW and Geolocation at the same time. 2023-11-22 12:37:02 -05:00
Vitor Pamplona
3253b8528f Fix URL Preview card when websites blend OG specs 2023-11-22 12:15:09 -05:00
Vitor Pamplona
81661c003a Fixes the writting of nostr addresses in the text with additional words connected to them without a space. It adds a space if the first char is in the bech alphabet. 2023-11-22 11:40:12 -05:00
Vitor Pamplona
fbe395f071 Adds logs for lifetime events. 2023-11-22 11:00:52 -05:00
Vitor Pamplona
d5cf64f048 Removes log message 2023-11-22 09:54:36 -05:00
Vitor Pamplona
7212d566c9 Fixes the blue notification dot appearing when the user receives a notification from a blocked account before downloading the blocklist 2023-11-22 09:10:15 -05:00
Vitor Pamplona
17490e948c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-22 08:44:53 -05:00
Vitor Pamplona
b392ea0dca Merge pull request #691 from greenart7c3/test_external_signer
Fixes/improvements to external signer
2023-11-22 08:44:46 -05:00
greenart7c3
9fc0c37845 fix relay reconnecting when returning from external signer 2023-11-22 08:33:41 -03:00
greenart7c3
6db031b208 fix sending zaps using amber, send some default permissions on login, save the signer package name 2023-11-22 08:33:41 -03:00
Vitor Pamplona
7246a545af Makes sure it doesn't crash on starting. 2023-11-21 14:17:32 -05:00
Vitor Pamplona
c8365439ee Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-21 13:29:51 -05:00
Vitor Pamplona
003d6bf018 Updating dependencies. 2023-11-21 13:29:48 -05:00
Vitor Pamplona
060286c292 Merge pull request #692 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-21 10:30:16 -05:00
Crowdin Bot
270935a30a New Crowdin translations by GitHub Action 2023-11-21 15:14:23 +00:00
Vitor Pamplona
05e9a1ac3f Fixes issue with cache of disappearing User Zaps in Notifications 2023-11-20 19:03:34 -05:00
Vitor Pamplona
9acad8b3f5 Narrowing the redownloads of reactions down. 2023-11-20 18:58:25 -05:00
Vitor Pamplona
5a9d6929e5 Make local image behave as remove images. 2023-11-20 15:52:18 -05:00
Vitor Pamplona
e45f02c110 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-19 17:58:27 -05:00
Vitor Pamplona
4f1ae72ab5 Merge pull request #690 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-19 17:47:22 -05:00
Vitor Pamplona
d4ed6661dd Fixes single click login screen 2023-11-19 17:45:00 -05:00
Crowdin Bot
2976349114 New Crowdin translations by GitHub Action 2023-11-19 22:31:15 +00:00
Vitor Pamplona
b1cfa2219d Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-19 17:29:20 -05:00
Vitor Pamplona
df9b764c1d Massive refactoring to unify internal signer and the Amber signer. 2023-11-19 17:28:17 -05:00
Vitor Pamplona
69941002df Adds JUnitRunner after upstream changed packages. 2023-11-18 15:26:48 -05:00
Vitor Pamplona
afc6a91c6b Merge pull request #685 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-10 09:12:56 -05:00
Crowdin Bot
c8b9858e4d New Crowdin translations by GitHub Action 2023-11-10 14:07:24 +00:00
Vitor Pamplona
c4ea6f3619 Merge pull request #686 from vicariousdrama/20231110-unabbrev-amounts-up-to-4-digit
Display amounts up to 4 digits without abbreviation
2023-11-10 09:05:43 -05:00
vicariousdrama
872d70c175 Allow amounts up to 4 digits without abbreviation 2023-11-10 07:21:06 +00:00
Vitor Pamplona
5ad6a2c70b Moves service manager to an Activity scoped class. 2023-11-09 15:25:59 -05:00
Vitor Pamplona
408b6793c0 Moves image cache to the Application class 2023-11-09 15:25:27 -05:00
Vitor Pamplona
43d03bc109 Caching total Zaps 2023-11-09 14:26:11 -05:00
Vitor Pamplona
9377c902a1 Moves creation of the LiveData set to a Synchronized block to avoid double creations without destruction. 2023-11-09 14:18:54 -05:00
Vitor Pamplona
57d97447f4 Fix error message when parsing LnInvoice 2023-11-09 14:07:50 -05:00
Vitor Pamplona
5493ce0dcf Updating dependencies 2023-11-09 12:02:28 -05:00
Vitor Pamplona
499149b7d9 Speeds up LNInvoice parsing 2023-11-09 10:56:37 -05:00
Vitor Pamplona
3fd0c61c0e Updating to support all crowdin languages 2023-11-08 17:08:23 -05:00
Vitor Pamplona
1d62d8ed73 v0.80.7 2023-11-08 14:41:07 -05:00
Vitor Pamplona
2c3a7daa65 Fixes bug when the app calls this method directly, bypassing the other checks in AccountViewModel 2023-11-08 14:32:54 -05:00
Vitor Pamplona
1771b6e667 v0.80.6 2023-11-07 17:39:18 -05:00
Vitor Pamplona
853dcb2127 minimizes race condition when pausing and restarting relay connections 2023-11-07 17:38:59 -05:00
Vitor Pamplona
b25f346370 v0.80.5 2023-11-07 16:44:49 -05:00
Vitor Pamplona
edd13fbe5b Uses the indexing operator 2023-11-07 16:31:36 -05:00
Vitor Pamplona
92c72e5a7b Merge pull request #669 from VictorieeMan/patch-1
changing externa sharing service for text notes to njump.me
2023-11-07 10:33:24 -05:00
Vitor Pamplona
e8e676afd5 Merge pull request #683 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-07 09:31:23 -05:00
Crowdin Bot
bb16ee7f0f New Crowdin translations by GitHub Action 2023-11-07 14:25:33 +00:00
Vitor Pamplona
4eb1c101b9 Merge pull request #682 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-07 09:23:49 -05:00
Crowdin Bot
eb67b6e6cf New Crowdin translations by GitHub Action 2023-11-07 02:19:53 +00:00
Vitor Pamplona
29541bd1e3 Merge pull request #681 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-06 21:18:43 -05:00
Crowdin Bot
08f94e9199 New Crowdin translations by GitHub Action 2023-11-07 02:18:14 +00:00
Vitor Pamplona
a7c7773a81 Merge pull request #680 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-06 21:16:55 -05:00
Crowdin Bot
75f2d5710a New Crowdin translations by GitHub Action 2023-11-07 02:15:40 +00:00
Vitor Pamplona
2bff9fe891 Merge pull request #679 from vitorpamplona/l10n_crowdin_translations
New Greek Translations
2023-11-06 21:14:07 -05:00
Vitor Pamplona
4d2c405aea Removes unnecessary methods 2023-11-06 20:45:53 -05:00
Crowdin Bot
ca24afd23e New Crowdin translations by GitHub Action 2023-11-07 01:15:03 +00:00
Vitor Pamplona
47e124c7fc Correctly highliting a notification when dragged 2023-11-06 20:13:00 -05:00
Vitor Pamplona
885aded701 Merge pull request #678 from vitorpamplona/l10n_crowdin_translations
New Spanish Translations
2023-11-06 16:38:32 -05:00
Crowdin Bot
e0729b7236 New Crowdin translations by GitHub Action 2023-11-06 21:29:19 +00:00
Vitor Pamplona
a058b75ee4 Refining the padding of chat messages and reaction row 2023-11-06 16:26:38 -05:00
Vitor Pamplona
0a5e84fd3c Refining layout of URL Previews 2023-11-05 11:27:30 -05:00
Vitor Pamplona
6fa36255b2 Refining the layout of Author Pictures 2023-11-05 11:27:15 -05:00
Vitor Pamplona
3de52a6c0d Simplifying Compose stack 2023-11-04 16:48:29 -04:00
Vitor Pamplona
6d17c8d79f Fixing translations of the same term but with different case. 2023-11-04 11:29:47 -04:00
Vitor Pamplona
26c4677b75 Forces reconnection when a new service is available. 2023-11-04 11:23:37 -04:00
Vitor Pamplona
c5882a9f05 Migrates the check if stream is online to a single compose object. 2023-11-04 11:23:18 -04:00
Vitor Pamplona
f99dc471a1 Merge pull request #676 from vitorpamplona/l10n_crowdin_translations
Updated Translations for Arabic, Greek, Spanish, Indonesian and Vietnamese
2023-11-03 17:25:13 -04:00
Crowdin Bot
25e31b00d2 New Crowdin translations by GitHub Action 2023-11-03 21:16:29 +00:00
Vitor Pamplona
c1a59a1d21 Improve position of video controlling buttons when top bars are present. 2023-11-03 17:14:41 -04:00
Vitor Pamplona
e0d2a71b47 Remove a compose recomposition between the started state and the isOnline state that is already cached. 2023-11-03 17:14:10 -04:00
Vitor Pamplona
ef90b82396 Updates Kotlin compiler version 2023-11-03 17:11:16 -04:00
Vitor Pamplona
9ec6e6d955 v0.80.4 2023-10-30 17:31:50 -04:00
Vitor Pamplona
6b7ec2caa9 Starting videos from Main, but in a thread. 2023-10-30 17:31:33 -04:00
Vitor Pamplona
17130c81c8 v0.80.3 2023-10-30 17:05:02 -04:00
Vitor Pamplona
0571312dca Fixing new line of Loading Animation and Download buttons for images. 2023-10-30 16:39:39 -04:00
Vitor Pamplona
717c3bc45e Generalizing InnerReaction Row 2023-10-30 15:44:15 -04:00
Vitor Pamplona
c304f7c69d Typo 2023-10-30 15:43:25 -04:00
Vitor Pamplona
6d2a1ec1b9 Remember's modifiers in animations 2023-10-30 14:57:57 -04:00
Vitor Pamplona
59f7f5257b Adds check in main thread to mark as seen on relays 2023-10-30 14:33:22 -04:00
Vitor Pamplona
ba1f2dc17d Minimizes Jittering when loading videos. 2023-10-30 14:05:36 -04:00
Vitor Pamplona
820def620c Improving rendering of reaction types 2023-10-30 12:52:19 -04:00
Vitor Pamplona
9fef3e9e3e Only updates notification dots once at every 3 seconds. 2023-10-30 12:27:59 -04:00
Vitor Pamplona
e8998a26d7 Follow yourself for new users. 2023-10-30 12:10:31 -04:00
Vitor Pamplona
221ab1ca1a Slight performance improvements 2023-10-30 12:04:10 -04:00
Vitor Pamplona
2f1e67779b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-30 09:35:23 -04:00
Vitor Pamplona
96663b822c Fixing colors of edit buttons 2023-10-30 09:34:43 -04:00
Vitor Pamplona
c7895d1cdd Merge pull request #674 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-30 08:58:22 -04:00
Crowdin Bot
85762c581a New Crowdin translations by GitHub Action 2023-10-30 12:44:53 +00:00
Vitor Pamplona
e189a4d68d Merge pull request #673 from greenart7c3/main
fix floatingactionbutton not showing again after changing screens
2023-10-30 08:43:19 -04:00
greenart7c3
3306dd88d2 fix floatingactionbutton not showing again after changing screens 2023-10-30 08:11:38 -03:00
Vitor Pamplona
18952bc8a7 Merge pull request #671 from matata2140/patch-1
Update strings.xml // values-nl
2023-10-28 22:31:43 -04:00
Vitor Pamplona
f6cfba6e1f Login and Logout already in IO threads 2023-10-28 21:26:36 -04:00
matata@nostrplebs.com
fb37ccd14f Update strings.xml // values-nl
fixed a couple translations, typo's and shortened a sentence for a nicer ux
2023-10-29 01:33:07 +02:00
Vitor Pamplona
3858c57816 More Stability improvements to Compose 2023-10-28 18:23:12 -04:00
Vitor Pamplona
572c11e847 Adjusting Stability of composables 2023-10-28 18:19:21 -04:00
Vitor Pamplona
bd70bd708b Stabilizing Note LiveData 2023-10-28 17:51:33 -04:00
Vitor Pamplona
9a2ecfc85d Adds images and posts without and extra line. 2023-10-28 16:58:39 -04:00
Vitor Pamplona
f20fd1aebe Fixes: https://github.com/vitorpamplona/amethyst/issues/629 2023-10-28 16:58:17 -04:00
Vitor Pamplona
2f2914077b Speeding up the boost method 2023-10-28 16:38:12 -04:00
Vitor Pamplona
712d498788 Fixing weird alignment of the Hightligh post title. 2023-10-28 15:38:59 -04:00
Vitor Pamplona
0e51731f69 v0.80.2 2023-10-28 12:36:27 -04:00
Vitor Pamplona
2cc9bb8b72 Slight improvement in the performance of Reaction Row's rendering 2023-10-28 12:31:44 -04:00
Vitor Pamplona
553bebb6cf Reducing the size of Subject labels 2023-10-28 12:14:02 -04:00
Vitor Pamplona
e91e5c366d Refining some of the Markdown to match Material3 Style 2023-10-28 12:07:22 -04:00
Vitor Pamplona
3222f88999 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-28 11:10:34 -04:00
Vitor Pamplona
9d32718c13 Update Profile Feed when Blocking/Unblocking the user. 2023-10-28 11:08:48 -04:00
Vitor Pamplona
9664e2f042 Removes log line. 2023-10-28 10:51:07 -04:00
Vitor Pamplona
979c04c303 Merge pull request #670 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-28 10:25:33 -04:00
Crowdin Bot
725baa8776 New Crowdin translations by GitHub Action 2023-10-28 14:24:43 +00:00
Vitor Pamplona
64c9505620 Reverts the New Post button animation due to bugs of not coming back 2023-10-28 10:22:50 -04:00
Vitor Pamplona
55db7221ad Updates dependencies 2023-10-28 10:22:01 -04:00
Vitor Pamplona
75dc55858c Fixes the use of decimals on Notification's chart 2023-10-28 10:08:06 -04:00
VictorieeMan
1f28d3f32f changing externa sharing service for text notes to njump.me
Changed "https://snort.social/e/" to "https://njump.me/"

Benefits of this change are the link previews that njump.me generates for shared content. The snort social links are just the url root and a long nevent, making different shares difficult to tell apart, and a bit scary to click on for some.

More benefits of njump.me listed on https://njump.me/, hosted by fiatjaf. The included nostr onboarding paired with client agnosticism is also beneficial.
2023-10-28 12:12:31 +02:00
Vitor Pamplona
c606212043 Reverts the hidden note LiveData to make sure the interface doesn't blink with the hidden note that just disappears ms later. 2023-10-27 17:58:40 -04:00
Vitor Pamplona
2ccf7ec2f0 Merge pull request #668 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-27 15:27:14 -04:00
Crowdin Bot
2baad54660 New Crowdin translations by GitHub Action 2023-10-27 19:24:26 +00:00
Vitor Pamplona
46cca5fd49 Merge pull request #667 from davotoula/updated_translations
Updated translations cs/de/sv/pt
2023-10-27 15:22:46 -04:00
David Kaspar
bb32477671 Updated translations for cs/de/sv/pt 2023-10-27 20:15:20 +01:00
David Kaspar
e89384ee61 fix typo in resource string name 2023-10-27 20:07:18 +01:00
Vitor Pamplona
b213137e7a v0.80.1 2023-10-27 14:02:40 -04:00
Vitor Pamplona
8fab00300a Fixes Benchmark tests 2023-10-27 14:01:12 -04:00
Vitor Pamplona
92a39c4305 Brings notifications back 2023-10-27 13:58:26 -04:00
Vitor Pamplona
116bb19bc6 v0.80.0 2023-10-27 13:12:50 -04:00
Vitor Pamplona
ce54d47f46 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-27 13:06:13 -04:00
Vitor Pamplona
6a8c756f7a Adds an explainer to install UnifiedPush apps if they are not installed. 2023-10-27 13:05:47 -04:00
Vitor Pamplona
64921ab240 Merge pull request #666 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-27 09:44:08 -04:00
Crowdin Bot
ab01113d0e New Crowdin translations by GitHub Action 2023-10-27 13:28:47 +00:00
Vitor Pamplona
26752084ae Merge pull request #665 from davotoula/additional_translations
added push notification translations to SV/DE/PT
2023-10-27 09:27:14 -04:00
David Kaspar
84bc9745be added to push notification translations to SV/DE/PT 2023-10-27 12:57:22 +01:00
Vitor Pamplona
b535fde0ff Merge pull request #664 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-26 19:31:41 -04:00
Crowdin Bot
424cbfcf1a New Crowdin translations by GitHub Action 2023-10-26 22:56:04 +00:00
Vitor Pamplona
56af44b00b - Makes sure to offer a None option on the PushNotification Distributor List
- Makes sure to not ask again if the person has already replied.
- Makes sure to not ask if there is no Unified Push app installed.
- Moves the code to a Component instead of a Custom Screen.
- Adds i18n messaging.
- Uses the existing Spinner Dialog for consistency
2023-10-26 18:53:42 -04:00
Vitor Pamplona
3b93198219 Removing Stop with Task from PushNotifications 2023-10-26 17:17:46 -04:00
Vitor Pamplona
98bb30fa59 Merge pull request #652 from KotlinGeekDev/oss-push-notifications
Oss push notifications(using UnifiedPush)
2023-10-26 17:16:44 -04:00
Vitor Pamplona
3ffb43539f Merge pull request #663 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-26 09:47:37 -04:00
Crowdin Bot
e3e989de53 New Crowdin translations by GitHub Action 2023-10-26 13:33:46 +00:00
Vitor Pamplona
86e34d4681 Merge pull request #662 from davotoula/br_translation
added to BR translations
2023-10-26 09:32:11 -04:00
David Kaspar
a0b79d1d3b added to BR translations 2023-10-26 14:26:55 +01:00
Vitor Pamplona
75ec92aff4 Merge pull request #661 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-26 08:56:31 -04:00
Crowdin Bot
ab38a369da New Crowdin translations by GitHub Action 2023-10-26 12:32:49 +00:00
Vitor Pamplona
460a99d95d Avoids creation of the LiveData in every recomposition. 2023-10-25 17:22:26 -04:00
Vitor Pamplona
a66e14f3eb Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-25 16:15:48 -04:00
Vitor Pamplona
dd1ade2f99 Avoids recompositions 2023-10-25 16:07:58 -04:00
Vitor Pamplona
177c98efb7 Merge pull request #659 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-25 15:35:10 -04:00
Crowdin Bot
8830ad3c27 New Crowdin translations by GitHub Action 2023-10-25 19:01:28 +00:00
Vitor Pamplona
903384d02a Making sure ViewModels are deleted when switching accounts. 2023-10-25 14:55:48 -04:00
Vitor Pamplona
4a8c020c3d Only call flows when there are new events to process. 2023-10-25 14:29:08 -04:00
Vitor Pamplona
4aaf25a384 Adds Lifecycle to all Flow object in compose. 2023-10-25 14:08:26 -04:00
Vitor Pamplona
a2f0c7f548 Merge pull request #657 from greenart7c3/nip-98
fix crash when uploading images/videos using external signer
2023-10-23 15:05:43 -04:00
greenart7c3
75bd97d0ae fix crash when uploading images/videos using external signer 2023-10-23 16:01:43 -03:00
Vitor Pamplona
f072b98fd7 Recreates the viewModel when the Account object changes. 2023-10-23 14:32:22 -04:00
Vitor Pamplona
d3d2845f10 v0.79.13 2023-10-23 13:07:47 -04:00
Vitor Pamplona
6831349f2f Slight adjustment on the rendering of hashtags. 2023-10-23 12:22:42 -04:00
Vitor Pamplona
5b0fc7982b Moves Relay and User Metadata update buttons from Post to Save. 2023-10-23 11:30:33 -04:00
Vitor Pamplona
10a0dc7f8a Fixes status update field when more than one status is available. 2023-10-23 11:29:59 -04:00
Vitor Pamplona
f92e13270d Solves NPE when the TextToSpeech engine isn't ready. 2023-10-23 11:15:33 -04:00
Vitor Pamplona
9dfb4d1a1b Making sure the UI update from language is in the Main thread. 2023-10-23 11:09:47 -04:00
Vitor Pamplona
91bfb60613 Update UI dependencies 2023-10-22 17:29:05 -04:00
Vitor Pamplona
191254d920 Makes sure only one npub is logged in at the same time 2023-10-22 17:16:06 -04:00
KotlinGeekDev
6dc82815b6 Cleanup endpoint URL before sending to push server(and storing it locally). 2023-10-22 17:45:44 +01:00
Vitor Pamplona
eb6e81f81e v0.79.12 2023-10-21 23:53:21 -04:00
Vitor Pamplona
19b3bca86a Uses stable version of accompanist 2023-10-21 23:52:50 -04:00
Vitor Pamplona
80df2eefed v0.79.11: Tablet view on chats 2023-10-21 16:42:41 -04:00
Vitor Pamplona
884a124c7e TwoPane display for chats in tablets and folds. 2023-10-21 16:23:34 -04:00
Vitor Pamplona
a284c1b9c6 Fixes orientation changes when in full screen 2023-10-21 16:22:20 -04:00
Vitor Pamplona
ccf428eff6 Updates Jsoup 2023-10-21 11:33:20 -04:00
Vitor Pamplona
96faac5aad v0.79.10 2023-10-20 21:58:04 -04:00
Vitor Pamplona
7060aa3ed0 Testing full dark 2023-10-20 21:57:48 -04:00
Vitor Pamplona
45a749e4bf v0.79.9 2023-10-20 18:11:53 -04:00
Vitor Pamplona
3321448dd2 Solving one of the OutOfMemory erros (too many translators instantiated at the same time) 2023-10-20 18:11:09 -04:00
Vitor Pamplona
893b88246e v0.79.8 2023-10-20 15:55:59 -04:00
Vitor Pamplona
8065b942bd Fixes the spacing of the account switcher to avoid getting too close to navigation buttons. 2023-10-20 15:54:37 -04:00
Vitor Pamplona
a1a2118b35 Removes debug color 2023-10-20 15:54:00 -04:00
Vitor Pamplona
17e59c5ae8 Fixes full screen dialog 2023-10-20 14:18:12 -04:00
Vitor Pamplona
39d84e33bb Activates singleTap to double zoom in images 2023-10-20 13:54:58 -04:00
Vitor Pamplona
87e8948d9a Fixes caching issue on the list selection in the top bar 2023-10-20 13:27:24 -04:00
Vitor Pamplona
8317a06b6f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-20 13:21:10 -04:00
Vitor Pamplona
1ab93adc22 Reduces the amount of competing threads to restart connections with relays. 2023-10-20 12:37:05 -04:00
Vitor Pamplona
af100fa0b4 Merge pull request #654 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-20 10:43:29 -04:00
Crowdin Bot
66443d029b New Crowdin translations by GitHub Action 2023-10-20 14:28:34 +00:00
Vitor Pamplona
6fe713107e Merge pull request #653 from greenart7c3/double_encryption_fix_2
fix double encryption when sending dms, when login with external signer set privKey to null to be sure we have no private keys
2023-10-20 10:27:17 -04:00
greenart7c3
800c0b0131 fix double encryption when sending dms, when login with external signer set privKey to null to be sure we have no private keys 2023-10-20 10:58:56 -03:00
KotlinGeekDev
e1a3975a07 Move NotificationScreen back and rework it in the manner of TranslatableRichTextViewer. 2023-10-20 03:40:33 +01:00
KotlinGeekDev
33bf305ab5 Add some padding to individual list items. 2023-10-19 13:25:09 +01:00
KotlinGeekDev
14ff6f03bf Merge remote-tracking branch 'origin/oss-push-notifications' into oss-push-notifications 2023-10-19 12:34:35 +01:00
KotlinGeekDev
68e91e405a Move NotificationScreen to different flavors to integrate Distributor dialog selection. Make some adjustments for handling changes to push distributors, and other fixes. 2023-10-19 12:33:43 +01:00
KotlinGeekDev
7b22b96968 Change getEndpoint function name to avoid clashing method signatures. 2023-10-19 12:33:43 +01:00
KotlinGeekDev
26107ff5e9 Introduce PushDistributorHandler and wire up PushMessageReceiver and PushNotificationUtils. 2023-10-19 12:33:43 +01:00
KotlinGeekDev
18931fb743 Introduce PushMessageReceiver and set up manifest. 2023-10-19 12:33:43 +01:00
KotlinGeekDev
f68f32f942 Add UnifiedPush dependency. 2023-10-19 12:33:43 +01:00
Vitor Pamplona
2892fa6fca v0.79.7 2023-10-18 18:27:35 -04:00
Vitor Pamplona
27286c7ffe Removing bold titles 2023-10-18 18:26:31 -04:00
Vitor Pamplona
d9b0fbe928 Merge pull request #636 from greenart7c3/main
fix floatingActionButton not hiding when scrolling
2023-10-18 18:11:26 -04:00
Vitor Pamplona
b15f203bc1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-18 18:04:18 -04:00
Vitor Pamplona
5d266cca20 - Moves app startup to an IO thread.
- Upgrades Shared Preferences serialization to a Single JSON object
- Simplifies Shared Preferences state changes
- LazyInitializes Video cache
2023-10-18 18:04:07 -04:00
KotlinGeekDev
904b070306 Move NotificationScreen to different flavors to integrate Distributor dialog selection. Make some adjustments for handling changes to push distributors, and other fixes. 2023-10-18 17:07:26 +01:00
Vitor Pamplona
37d4efd7ba Merge pull request #651 from greenart7c3/test
fix crash when sending giftwraps with amber
2023-10-18 08:22:55 -04:00
greenart7c3
ece8998ba6 fix crash when sending giftwraps with amber 2023-10-18 09:07:08 -03:00
KotlinGeekDev
3f5000f278 Change getEndpoint function name to avoid clashing method signatures. 2023-10-18 03:30:02 +01:00
KotlinGeekDev
68f4752c66 Introduce PushDistributorHandler and wire up PushMessageReceiver and PushNotificationUtils. 2023-10-18 03:20:22 +01:00
Vitor Pamplona
904a50be1f v0.79.6 2023-10-17 18:30:50 -04:00
Vitor Pamplona
35f6ecad59 Stops PushNotifications when the app is killed. 2023-10-17 18:10:10 -04:00
Vitor Pamplona
2a328ca120 Dump memory states to debug OOO 2023-10-17 18:09:58 -04:00
Vitor Pamplona
1e83dbfbe3 Only changes Username if value changes. 2023-10-17 18:09:39 -04:00
Vitor Pamplona
e703c08634 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-17 16:33:21 -04:00
Vitor Pamplona
f8a574375a Updating Jackson 2023-10-17 16:33:16 -04:00
Vitor Pamplona
cfcafe5b2c Merge pull request #650 from believethehype/patch-1
Added #thenostr hashtag icon
2023-10-17 16:15:31 -04:00
believethehype
3ea7b8194d Added #thenostr hashtag icon 2023-10-17 22:13:39 +02:00
Vitor Pamplona
1a7ae33fa8 Activates color for the zaps in the Notification chart 2023-10-17 16:13:28 -04:00
Vitor Pamplona
2bbe126c8d Configuration cache was creating a lot of build issues 2023-10-17 15:39:36 -04:00
Vitor Pamplona
f2e6efe2b7 Improving Git hooks 2023-10-17 15:36:26 -04:00
Vitor Pamplona
404e6cd862 Moves playback services to a package 2023-10-17 15:23:43 -04:00
Vitor Pamplona
4ab04477f8 v0.79.5 2023-10-17 11:14:50 -04:00
Vitor Pamplona
611e8f1a6d Forces a filter reset after authentication 2023-10-16 14:39:05 -04:00
Vitor Pamplona
c8fd810bd3 Adds log of bad signature event. 2023-10-16 14:38:31 -04:00
Vitor Pamplona
c619a9000c Resolving a duplicated entry on relay list in Note. 2023-10-16 12:07:17 -04:00
Vitor Pamplona
3d70614797 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-16 12:05:52 -04:00
Vitor Pamplona
c29b4b8e5f Avoids testing the signature when the id or sig fields are blank 2023-10-16 12:05:48 -04:00
Vitor Pamplona
4286b64b41 Improves the feedback to messages from relays. 2023-10-16 11:54:14 -04:00
Vitor Pamplona
e8c50cbf54 Merge pull request #649 from jiftechnify/handle-geohash-error
Add exception handling for parsing geohash
2023-10-16 09:22:21 -04:00
jiftechnify
407807d871 add exception handling for parsing geohash 2023-10-16 16:52:28 +09:00
Vitor Pamplona
202af650de v0.79.4 2023-10-15 18:07:57 -04:00
Vitor Pamplona
e1e42ed500 Only adds a border to video controls when in full screen 2023-10-15 18:07:40 -04:00
Vitor Pamplona
1767cc74a9 Activates images controllers on click 2023-10-15 17:15:48 -04:00
Vitor Pamplona
4028018605 Adds a border in the image dialog to avoid overriding controllers. 2023-10-15 17:07:35 -04:00
Vitor Pamplona
33f8b6d6d8 Trying to fix the image bleed into the previous and next page in the Video feed. 2023-10-15 17:07:02 -04:00
Vitor Pamplona
c738436ddc Merge pull request #647 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-15 15:43:21 -04:00
Crowdin Bot
467174ebf8 New Crowdin translations by GitHub Action 2023-10-15 19:42:54 +00:00
Vitor Pamplona
f9fed8a04f Update dependencies 2023-10-15 15:41:24 -04:00
Vitor Pamplona
00194c2f0d Organize imports 2023-10-15 15:35:49 -04:00
Vitor Pamplona
37be1e8c46 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-14 14:52:24 -04:00
Vitor Pamplona
357f8f6f94 Merge pull request #646 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-14 14:51:09 -04:00
Vitor Pamplona
ba4a594a41 Fixes all the other mute list feeds 2023-10-14 14:50:54 -04:00
Crowdin Bot
928a87363d New Crowdin translations by GitHub Action 2023-10-14 18:47:24 +00:00
Vitor Pamplona
1708c48b0c Merge pull request #639 from davotoula/bugfix_mute_feed_is_always_empty
Bugfix mute feed is always empty
2023-10-14 14:46:05 -04:00
KotlinGeekDev
17adbf3603 Introduce PushMessageReceiver and set up manifest. 2023-10-12 19:48:54 +01:00
KotlinGeekDev
57ff5c63b0 Add UnifiedPush dependency. 2023-10-12 18:36:59 +01:00
Vitor Pamplona
4f4cab6a6c Merge pull request #642 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-11 15:34:34 -04:00
Crowdin Bot
e848b58a60 New Crowdin translations by GitHub Action 2023-10-11 19:26:44 +00:00
Vitor Pamplona
5c8f0ad5ce Merge pull request #641 from greenart7c3/double_encryption_fix
fix double encryption on nip04 using amber
2023-10-11 15:25:20 -04:00
greenart7c3
173245d900 fix double encryption on nip04 2023-10-11 15:14:10 -03:00
Vitor Pamplona
dd2e590475 Merge pull request #640 from greenart7c3/encryption_amber
try to fix messages being encrypted twice using amber
2023-10-09 12:14:13 -04:00
greenart7c3
5bc4aab8d8 try to fix messages being encrypted twice 2023-10-09 11:38:29 -03:00
greenart7c3
94308a6098 Merge branch 'vitorpamplona:main' into main 2023-10-09 12:45:42 +00:00
David Kaspar
c7861dfac4 change mute test to equality with string including event 30000 and user hex 2023-10-09 13:53:09 +02:00
David Kaspar
071df0b6d3 change equality test of Mute list to regular expression 2023-10-09 13:13:10 +02:00
David Kaspar
ac0fbc7435 change equality test of Mute list from equals "mute" to ends with mute" 2023-10-09 13:01:32 +02:00
Vitor Pamplona
275051ed30 Fixes visibility of the bottom and top bar when the user comes back to the app. 2023-10-06 16:37:55 -04:00
Vitor Pamplona
3c4cf03684 Merge pull request #638 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-06 16:37:14 -04:00
Crowdin Bot
2f1c221a7c New Crowdin translations by GitHub Action 2023-10-06 20:29:37 +00:00
Vitor Pamplona
6748abbf70 Fixes failure to load long form content with d tags reserved url chars 2023-10-06 16:27:40 -04:00
Vitor Pamplona
e04a35afed Fixes bug of not immediately loading chat rooms 2023-10-06 16:27:09 -04:00
greenart7c3
f574a1dae5 fix floatingActionButton not hiding 2023-10-06 07:01:49 -03:00
Vitor Pamplona
f67ff43eb3 Moves the Post button on chat screens to a > Button 2023-10-04 09:20:46 -04:00
Vitor Pamplona
55a4b1937f v0.79.3 2023-10-03 11:50:42 -04:00
Vitor Pamplona
b8b41f840a Hide's the Video Full Screen dialog buttons together with video controls. 2023-10-03 11:50:22 -04:00
Vitor Pamplona
9f5a71020d Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-03 10:21:15 -04:00
Vitor Pamplona
e8c9e73985 Fixes the saving of the video position between screens 2023-10-03 10:21:06 -04:00
Vitor Pamplona
e0dba87286 Merge pull request #632 from atrifat/update-indonesian-translation
Update Indonesian translation
2023-10-03 09:47:45 -04:00
Vitor Pamplona
3c308788d8 Merge pull request #635 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-03 08:55:05 -04:00
Crowdin Bot
3d28715713 New Crowdin translations by GitHub Action 2023-10-03 12:52:46 +00:00
Vitor Pamplona
61d2030934 Merge pull request #634 from davotoula/main
Updated translations for cs/de/sv
2023-10-03 08:51:26 -04:00
David Kaspar
56c67b9ec6 Updated translations for cs/de/sv 2023-10-03 12:07:11 +02:00
David Kaspar
77ed529c26 Updated translations for cs/de/sv 2023-10-03 11:58:38 +02:00
Vitor Pamplona
7eddf4a12a Moves Following icon from crossfade to animatedVisibility. 2023-10-02 18:11:05 -04:00
Vitor Pamplona
ff69a4168b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-02 17:43:43 -04:00
Vitor Pamplona
cec204b7ae Breaks down Compose components in the Discovery tab. 2023-10-02 17:43:38 -04:00
Vitor Pamplona
188ef3762d Allows Base64 images on profiles. 2023-10-02 17:31:34 -04:00
Vitor Pamplona
64c9acb4b2 Removes duplicated method 2023-10-02 17:30:43 -04:00
Vitor Pamplona
c25018bb64 Merge pull request #633 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-02 15:27:37 -04:00
Crowdin Bot
c1a5b855df New Crowdin translations by GitHub Action 2023-10-02 19:18:03 +00:00
Vitor Pamplona
0da031fae4 Fixes bug of messages not reappearing after memory trimming. 2023-10-02 15:07:08 -04:00
Vitor Pamplona
72dff060d2 Moves activeOnScreen calculations to a LaunchedEffect 2023-10-02 14:45:04 -04:00
Vitor Pamplona
d6f4ffafa1 Only changes shouldShow if the value is different. 2023-10-02 14:44:29 -04:00
Vitor Pamplona
4b36217699 Moves audiothumbnail loader to the viewModel scope 2023-10-02 14:43:49 -04:00
Rif'at Ahdi R
9a109e6fc1 Update Indonesian translation 2023-10-02 09:17:26 +08:00
Vitor Pamplona
227aac5b76 v0.79.2 2023-10-01 12:07:51 -04:00
Vitor Pamplona
82ea72d2d1 Removes last mention to a string 2023-10-01 12:07:17 -04:00
Vitor Pamplona
f1637c3d20 v0.79.1 2023-10-01 11:42:53 -04:00
Vitor Pamplona
07be841246 waits 3 seconds before starting the rest. 2023-10-01 11:42:47 -04:00
Vitor Pamplona
3ce5d92556 Making sure shared flows don't suspend. 2023-10-01 11:42:31 -04:00
Vitor Pamplona
8f11396e00 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-10-01 11:29:30 -04:00
Vitor Pamplona
2259523387 Merge pull request #630 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-10-01 11:29:25 -04:00
Vitor Pamplona
d67711b123 Speeds up cold startup. 2023-10-01 11:28:59 -04:00
Vitor Pamplona
c89ca51a1b Updates dependencies 2023-10-01 11:10:37 -04:00
Crowdin Bot
c5c951dd59 New Crowdin translations by GitHub Action 2023-10-01 15:09:21 +00:00
Vitor Pamplona
9f68f0227e Moves many Toasts to better designed Information Dialogs. 2023-10-01 11:06:05 -04:00
Vitor Pamplona
7faed2ddd3 stringResource seems to be slower than context.getString 2023-09-30 18:46:21 -04:00
Vitor Pamplona
7dceb701b2 Moves check if it is online to the viewModel scope 2023-09-30 18:45:58 -04:00
Vitor Pamplona
e454ccc326 Uses the "host" property as the creator of the stream. 2023-09-30 17:43:46 -04:00
Vitor Pamplona
bac50668e8 Speeds the composition of the Topbar 2023-09-30 17:23:27 -04:00
Vitor Pamplona
ff9e1556d5 Increases the performance of the Bottom bar's Notification dot calculations 2023-09-30 16:24:51 -04:00
Vitor Pamplona
dffb49b071 Uses Lists name instead of d-tag on the Top Bar 2023-09-30 11:45:46 -04:00
Vitor Pamplona
7387d9060f Improves error zap message screens for Material3 2023-09-30 11:37:23 -04:00
Vitor Pamplona
52e1842174 Fixes the repeat payments when the NWC is not setup correctly. 2023-09-30 11:17:17 -04:00
Vitor Pamplona
d738e5cbaf v0.79.0 2023-09-29 16:27:04 -04:00
Vitor Pamplona
386b6db5ff Fixes relay type loading from the default list when they are not present in the user's account yet. 2023-09-29 16:23:42 -04:00
Vitor Pamplona
47d57e5a44 Only adds search relays if none of the current relays are active for search. 2023-09-29 16:09:11 -04:00
Vitor Pamplona
92068ca64f Fixing the inclusion of search relays 2023-09-29 16:03:59 -04:00
Vitor Pamplona
349510e900 Updates gradle plugin 2023-09-29 15:48:24 -04:00
Vitor Pamplona
948d0f4bea Merge pull request #627 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-29 14:41:14 -04:00
Crowdin Bot
b9a5586167 New Crowdin translations by GitHub Action 2023-09-29 18:30:00 +00:00
Vitor Pamplona
54edddbd2d Migrates to Material 3 2023-09-29 13:57:10 -04:00
Vitor Pamplona
b9c83426dc Merge pull request #624 from greenart7c3/main
add settings to load profile pictures only on wifi,never or always
2023-09-27 14:30:44 -04:00
greenart7c3
40dae875b8 Merge branch 'main' of https://github.com/greenart7c3/amethyst 2023-09-27 10:48:53 -03:00
greenart7c3
e762ad5e18 add settings to load profile pictures 2023-09-27 10:48:26 -03:00
Vitor Pamplona
9b1bd2127f Merge pull request #623 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-26 11:07:27 -04:00
Crowdin Bot
88701a97c7 New Crowdin translations by GitHub Action 2023-09-26 15:03:50 +00:00
Vitor Pamplona
600c927594 v0.78.2 2023-09-26 11:01:42 -04:00
Vitor Pamplona
1387a0f62b Adds note to make sure we know the callback of a prepare video runs in the main thread. 2023-09-26 10:56:28 -04:00
Vitor Pamplona
e3df94338f Fixes the access to playback state from the wrong thread 2023-09-26 10:55:31 -04:00
Vitor Pamplona
ee466aa1e4 v0.78.1 2023-09-25 18:28:48 -04:00
Vitor Pamplona
9704d61a7a more remembers 2023-09-25 18:28:35 -04:00
Vitor Pamplona
69d7e82b71 Increasing video cache from 90 to 150MB 2023-09-25 17:36:57 -04:00
Vitor Pamplona
288d80d163 only updates reports for a different note 2023-09-25 17:26:41 -04:00
Vitor Pamplona
b1edf4e934 Improving the Stability of VideoView 2023-09-25 17:09:36 -04:00
Vitor Pamplona
af9b0b444c Fix for not clearing the new post message. It adds a delay to allow the composer to save the viewModel after calling cancel 2023-09-25 15:29:13 -04:00
Vitor Pamplona
a3865e68bd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-25 14:27:45 -04:00
Vitor Pamplona
ed70585e90 Merge pull request #620 from jiftechnify/fix-relay-content-link
Fix contact link in relay information dialog
2023-09-25 14:27:38 -04:00
Vitor Pamplona
f707fd007b Merge pull request #622 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-25 14:26:39 -04:00
Crowdin Bot
d92e30dba3 New Crowdin translations by GitHub Action 2023-09-25 18:22:35 +00:00
Vitor Pamplona
4afa5c1789 Merge pull request #621 from greenart7c3/main
add notifications for users of amber
2023-09-25 14:21:10 -04:00
Vitor Pamplona
e89e8e5d01 moves mark as read to the AccountViewModel scope. 2023-09-25 14:19:27 -04:00
greenart7c3
52f600485b add notifications for users of amber 2023-09-25 14:55:44 -03:00
jiftechnify
b5a0d65f3c fix contact link in relay information dialog 2023-09-26 00:49:00 +09:00
Vitor Pamplona
fc4433e7ae Adds NIP19 parser to the AccountViewModel thread scope 2023-09-25 11:35:39 -04:00
Vitor Pamplona
f2b6a9bedb Avoids creating nip94 and nip95 events with blank alts 2023-09-25 11:12:02 -04:00
Vitor Pamplona
35f0b1291f Performance Improvements to PreviewCards 2023-09-25 11:04:53 -04:00
Vitor Pamplona
045699ecd9 Disable swipe to change page in the hope page 2023-09-25 10:28:08 -04:00
Vitor Pamplona
74a2b46508 Forces the presence of the name field in kind 1 as per https://github.com/nostr-protocol/nips/pull/794 2023-09-25 10:11:25 -04:00
Vitor Pamplona
feaa594e52 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-25 09:55:28 -04:00
Vitor Pamplona
d966541ea7 Updating dependencies 2023-09-25 09:54:58 -04:00
Vitor Pamplona
ecfc287871 Merge pull request #619 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-25 08:50:35 -04:00
Crowdin Bot
c10461126a New Crowdin translations by GitHub Action 2023-09-25 12:45:27 +00:00
Vitor Pamplona
20903cefc8 Merge pull request #618 from davotoula/main
Updated translations for cs/de/sv
2023-09-25 08:44:11 -04:00
David Kaspar
e1caf40c3a Updated translations for cs/de/sv 2023-09-25 11:13:05 +02:00
Vitor Pamplona
6e70034758 v0.78.0 2023-09-24 18:40:15 -04:00
Vitor Pamplona
ee30ff28f6 UI Improvements for Hidden Words 2023-09-24 18:36:43 -04:00
Vitor Pamplona
03654b5ebd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-24 18:14:44 -04:00
Vitor Pamplona
7071174aed BugFix for restarting the relay connection when switching accounts after Amber's integration. 2023-09-24 18:12:50 -04:00
Vitor Pamplona
2c27531056 Merge pull request #617 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-24 15:27:58 -04:00
Crowdin Bot
57d5556728 New Crowdin translations by GitHub Action 2023-09-24 19:13:23 +00:00
Vitor Pamplona
f4da2ae6be Mute keywords 2023-09-24 15:10:50 -04:00
Vitor Pamplona
57dfe3af8c Reverting to have videos starting from the IO thread. 2023-09-24 10:35:51 -04:00
Vitor Pamplona
618d14a965 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-23 14:19:41 -04:00
Vitor Pamplona
ee95942289 Merge pull request #615 from greenart7c3/main
only show backup keys if account has a private key
2023-09-23 12:11:03 -04:00
Vitor Pamplona
c6c15c3ec7 Adding an option to avoid immersive scrolling 2023-09-23 12:10:17 -04:00
greenart7c3
ed4c51b664 Merge branch 'main' of https://github.com/greenart7c3/amethyst 2023-09-23 12:40:17 -03:00
greenart7c3
b3f39434ee only show backup keys if account has a private key 2023-09-23 12:24:17 -03:00
Vitor Pamplona
30ded17581 v0.77.8 2023-09-22 16:57:14 -04:00
Vitor Pamplona
19557bf358 Filters zap payments outside the established min-max parameters on polls. 2023-09-22 16:50:25 -04:00
Vitor Pamplona
bdc2cbbb99 Adds Mutiny button 2023-09-22 16:23:19 -04:00
Vitor Pamplona
82e2d15aaf Moves Live streaming from the top bar to the screen to avoid cancelling the video on scrolling. 2023-09-22 14:51:21 -04:00
Vitor Pamplona
6aff31886e Fixes website URLs without schema (http://, https://, etc) 2023-09-22 14:37:51 -04:00
Vitor Pamplona
7cc089f0ee Video playback creation must be run in the main thread :( 2023-09-22 14:19:29 -04:00
Vitor Pamplona
6c025f7274 Speeding up the display of unused hashtags 2023-09-22 14:19:07 -04:00
Vitor Pamplona
d5220324c4 Turns out the lifecycle owner changes quite a bit and thus it's a requirement to make it the key of a Disposable Effect. 2023-09-22 13:46:30 -04:00
Vitor Pamplona
a0308bd3a1 Merge pull request #614 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-22 12:18:34 -04:00
Crowdin Bot
856b4ca2c1 New Crowdin translations by GitHub Action 2023-09-22 15:55:45 +00:00
Vitor Pamplona
8abfd10f0c Merge pull request #613 from greenart7c3/main
update pt-br translations
2023-09-22 11:54:13 -04:00
greenart7c3
1bc4d33bb7 Merge branch 'vitorpamplona:main' into main 2023-09-22 11:27:48 -03:00
greenart7c3
35c67f692a update pt-br translations 2023-09-22 11:11:47 -03:00
Vitor Pamplona
a319902cf7 Merge pull request #612 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-22 09:47:54 -04:00
Crowdin Bot
2bc8d26b4f New Crowdin translations by GitHub Action 2023-09-22 13:40:33 +00:00
Vitor Pamplona
402da58391 Merge pull request #611 from greenart7c3/main
fix crash when editing account
2023-09-22 09:39:28 -04:00
Vitor Pamplona
b533afe27f Merge pull request #610 from davotoula/main
Updated translations for cs/de/sv
2023-09-22 09:39:05 -04:00
greenart7c3
6a4afb45dc fix crash when editing account 2023-09-22 05:13:47 -03:00
David Kaspar
73a718e6c7 Updated translations for cs/de/sv 2023-09-22 09:29:58 +02:00
Vitor Pamplona
140f6e3318 Refactoring to move markdown parser away from the Compose Class 2023-09-21 16:57:43 -04:00
Vitor Pamplona
073add9e71 Reduce padding of custom emojis 2023-09-21 14:30:56 -04:00
Vitor Pamplona
35ecf1f7a7 v0.77.7 2023-09-21 14:15:25 -04:00
Vitor Pamplona
71db62e5a5 Resets the bar visibility when switching screens. 2023-09-21 14:14:40 -04:00
Vitor Pamplona
ff82b52a5b Removes the need for the payment popup for only one invoice. 2023-09-21 14:03:38 -04:00
Vitor Pamplona
18e81de26d v0.77.6 2023-09-21 10:38:02 -04:00
Vitor Pamplona
27f37fc158 Removes the flickering on chat screens when the bottom bar disappears. 2023-09-21 10:32:06 -04:00
Vitor Pamplona
6638ae1d7b no need for coroutines on nav anymore 2023-09-21 09:20:11 -04:00
Vitor Pamplona
42094c2a70 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-21 09:09:35 -04:00
Vitor Pamplona
932abdd412 Fixes video keep playing bug when pressing the bottom bar twice. 2023-09-21 09:09:24 -04:00
Vitor Pamplona
b4bb6f2b15 Merge pull request #608 from davotoula/main
"show more" text folding enhancement
2023-09-21 08:19:02 -04:00
David Kaspar
4c7b523de1 Merge remote-tracking branch 'origin/show_more_enhancement' into show_more_enhancement 2023-09-21 08:47:40 +02:00
David Kaspar
59608e9acc Combine new line limit with previous character checking (use the smallest) 2023-09-21 08:47:09 +02:00
David Kaspar
77afedb2d0 Introduced line limit in addition to existing character limit for "show more" folding of text 2023-09-21 08:47:09 +02:00
David Kaspar
ccb2c626a7 Combine new line limit with previous character checking (use the smallest) 2023-09-21 08:27:16 +02:00
David Kaspar
9b6eec5fdd Introduced line limit in addition to existing character limit for "show more" folding of text 2023-09-21 08:11:25 +02:00
361 changed files with 23291 additions and 13127 deletions

View File

@@ -3,30 +3,39 @@
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" 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" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

2
.idea/kotlinc.xml generated
View File

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

View File

@@ -78,7 +78,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Moderated Communities (NIP-72)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
- [x] Classifieds (Kind:30403)
- [x] Classifieds (Kind:30402)
- [x] Private Messages and Small Groups (NIP-24)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44)
@@ -204,7 +204,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
# Screenshots
| Home Feed | Messages | Live Streams | Notifications |
| FollowFeeds | ChatsGroup | LiveStreams | Notifications |
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |

View File

@@ -1,7 +1,7 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.5.1"
id 'org.jlleitschuh.gradle.ktlint' version "11.6.1"
id 'com.google.gms.google-services'
}
@@ -13,14 +13,78 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 302
versionName "0.77.5"
versionCode 340
versionName "0.82.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'in', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
resourceConfigurations += [
'ar',
'bn-rBD',
'cs',
'cy-rGB',
'da-rDK',
'de',
'el-rGB',
'en-rGB',
'eo',
'es',
'es-rES',
'es-rMX',
'es-rUS',
'et-rEE',
'fa',
'fi-rFI',
'fo-rFO',
'fr',
'fr-rCA',
'gu-rIN',
'hi-rIN',
'hr-rHR',
'hu',
'in',
'in-rID',
'it-rIT',
'iw-rIL',
'ja',
'kk-rKZ',
'ko-rKR',
'ks-rIN',
'ku-rTR',
'lt-rLT',
'ne-rNP',
'night',
'nl',
'nl-rBE',
'pcm-rNG',
'pl-rPL',
'pt-rBR',
'pt-rPT',
'ru',
'ru-rUA',
'sa-rIN',
'sl-rSI',
'so-rSO',
'sr-rSP',
'ss-rZA',
'sv-rSE',
'sw-rKE',
'sw-rTZ',
'ta',
'th',
'tr',
'uk',
'ur-rIN',
'uz-rUZ',
'vi-rVN',
'zh',
'zh-rCN',
'zh-rHK',
'zh-rSG',
'zh-rTW'
]
}
buildTypes {
@@ -73,7 +137,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.5.1"
kotlinCompilerExtensionVersion "1.5.3"
}
packagingOptions {
@@ -81,15 +145,20 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
lint {
disable 'MissingTranslation'
}
testOptions {
unitTests.returnDefaultValues = true
}
}
dependencies {
implementation project(path: ':quartz')
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.7.2'
implementation 'androidx.activity:activity-compose:1.8.1'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -102,9 +171,15 @@ dependencies {
// Observe Live data as State
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
implementation "androidx.compose.material:material:$compose_ui_version"
// Material 3 Design
implementation "androidx.compose.material3:material3:${material3_version}"
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
// Adaptive Layout / Two Pane
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
implementation "com.google.accompanist:accompanist-adaptive:0.32.0"
// Lifecycle
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
@@ -112,7 +187,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation 'net.engawapg.lib:zoomable:1.5.1'
implementation 'net.engawapg.lib:zoomable:1.5.2'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -121,15 +196,19 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// HTML Parsing for Link Preview
implementation 'org.jsoup:jsoup:1.16.1'
implementation 'org.jsoup:jsoup:1.17.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
implementation "androidx.media3:media3-exoplayer:$media3_version"
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
// important for proxy / tor
implementation "androidx.media3:media3-datasource-okhttp:${exoplayer_version}"
implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -154,26 +233,26 @@ dependencies {
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced')
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
// Google services model the translate text
playImplementation 'com.google.mlkit:translate:17.0.1'
playImplementation 'com.google.mlkit:translate:17.0.2'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.2.3')
playImplementation platform('com.google.firebase:firebase-bom:32.6.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
//PushNotifications(FDroid)
fdroidImplementation 'com.github.UnifiedPush:android-connector:2.2.0'
// Charts
implementation "com.patrykandpatrick.vico:core:${vico_version}"
implementation "com.patrykandpatrick.vico:compose:${vico_version}"
@@ -187,23 +266,20 @@ dependencies {
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.7'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
testImplementation 'io.mockk:mockk:1.13.8'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
}
ktlint {
disabledRules.set(["no-wildcard-imports"])
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
// NB: Android Studio can't find the imports; this does not affect the
// actual build since Gradle can find them just fine.

View File

@@ -2,77 +2,116 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.FileServer
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.amethyst.ui.actions.Nip96Uploader
import com.vitorpamplona.quartz.crypto.KeyPair
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val contentType = "image/gif"
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
private fun testBase(server: FileServer) {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
val contentTypePng = "image/png"
val imagePng = "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
val countDownLatch = CountDownLatch(1)
var url: String? = null
var error: String? = null
ImageUploader.account = Account(
KeyPair()
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo = Nip96Retriever().loadInfo(
server.baseUrl
)
ImageUploader.uploadImage(
val bytes = Base64.getDecoder().decode(imagePng)
val inputStream = bytes.inputStream()
val account = Account(KeyPair())
val result = Nip96Uploader(account).uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
server,
onSuccess = { newUrl, contentType ->
println("Uploaded $contentType to $url")
url = newUrl
countDownLatch.countDown()
},
onError = {
println("Failed to Upload")
error = it.message
countDownLatch.countDown()
contentTypePng,
alt = null,
sensitiveContent = null,
serverInfo,
onProgress = {
}
)
countDownLatch.await()
val url = result.tags!!.first() { it[0] == "url" }.get(1)
val size = result.tags!!.firstOrNull() { it[0] == "size" }?.get(1)?.ifBlank { null }
val dim = result.tags!!.firstOrNull() { it[0] == "dim" }?.get(1)?.ifBlank { null }
val hash = result.tags!!.firstOrNull() { it[0] == "x" }?.get(1)?.ifBlank { null }
val contentType = result.tags!!.first() { it[0] == "m" }.get(1)
val ox = result.tags!!.first() { it[0] == "ox" }.get(1)
Assert.assertNull(error)
Assert.assertTrue(url?.startsWith("http") == true)
Assert.assertTrue(url.startsWith("http"))
val imageData: ByteArray = ImageDownloader().waitAndGetImage(url) ?: run {
fail("Should not be null")
return
}
FileHeader.prepare(
imageData,
contentTypePng,
null,
onReady = {
if (dim != null) {
assertEquals(dim, it.dim)
}
if (size != null) {
assertEquals(size, it.size.toString())
}
if (hash != null) {
assertEquals(hash, it.hash)
}
},
onError = {
fail("It should not fail")
}
)
// delay(1000)
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
}
@Test()
fun testImgurUpload() = runBlocking {
testBase(ImgurServer())
fun testNostrCheck() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
}
@Test()
fun testNostrBuildUpload() = runBlocking {
testBase(NostrBuildServer())
fun testNostrage() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
}
@Test()
fun testNostrImgUpload() = runBlocking {
testBase(NostrImgServer())
fun testSove() = runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
}
@Test()
fun testNostrFilesDevUpload() = runBlocking {
testBase(NostrFilesDevServer())
fun testNostrBuild() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
}
@Test()
fun testSovbit() = runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
}
@Test()
fun testVoidCat() = runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
}
}

View File

@@ -4127,6 +4127,31 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
}
}
@Test
fun testUrlsEndingInPeriod() {
val text = "Thats it! http://vitorpamplona.com/. Thats the note"
val state = RichTextParser().parseText(text, EmptyTagList)
printStateForDebug(state)
val expectedResult = listOf<String>(
"RegularText(Thats)",
"RegularText(it!)",
"Link(http://vitorpamplona.com/.)",
"RegularText(Thats)",
"RegularText(the)",
"RegularText(note)"
)
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals(
expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
)
}
}
private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEach { paragraph ->
paragraph.words.forEach { seg ->

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".Amethyst">
<receiver
android:exported="true"
android:enabled="true"
android:name=".service.notifications.PushMessageReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.vitorpamplona.amethyst.service.lang
object LanguageTranslatorService {
fun clear() {}
}

View File

@@ -0,0 +1,76 @@
package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import org.unifiedpush.android.connector.UnifiedPush
interface PushDistributorActions {
fun getSavedDistributor(): String
fun getInstalledDistributors(): List<String>
fun saveDistributor(distributor: String)
fun removeSavedDistributor()
}
object PushDistributorHandler : PushDistributorActions {
private val appContext = Amethyst.instance.applicationContext
private val unifiedPush: UnifiedPush = UnifiedPush
private var endpointInternal = ""
val endpoint = endpointInternal
fun getSavedEndpoint() = endpoint
fun setEndpoint(newEndpoint: String) {
endpointInternal = newEndpoint
Log.d("PushHandler", "New endpoint saved : $endpointInternal")
}
fun removeEndpoint() {
endpointInternal = ""
}
override fun getSavedDistributor(): String {
return unifiedPush.getDistributor(appContext)
}
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
override fun getInstalledDistributors(): List<String> {
return unifiedPush.getDistributors(appContext)
}
fun formattedDistributorNames(): List<String> {
val distributorsArray = getInstalledDistributors().toTypedArray()
val distributorsNameArray = distributorsArray.map {
try {
val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appContext.packageManager.getApplicationInfo(
it,
PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong()
)
)
} else {
appContext.packageManager.getApplicationInfo(it, 0)
}
appContext.packageManager.getApplicationLabel(ai)
} catch (e: PackageManager.NameNotFoundException) {
it
} as String
}.toTypedArray()
return distributorsNameArray.toList()
}
override fun saveDistributor(distributor: String) {
unifiedPush.saveDistributor(appContext, distributor)
unifiedPush.registerApp(appContext)
}
override fun removeSavedDistributor() {
unifiedPush.safeRemoveDistributor(appContext)
}
fun forceRemoveDistributor(context: Context) {
unifiedPush.forceRemoveDistributor(context)
}
}

View File

@@ -0,0 +1,92 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.util.Log
import android.util.LruCache
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
class PushMessageReceiver : MessagingReceiver() {
private val TAG = "Amethyst-OSSPushReceiver"
private val appContext = Amethyst.instance.applicationContext
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val eventCache = LruCache<String, String>(100)
private val pushHandler = PushDistributorHandler
override fun onMessage(context: Context, message: ByteArray, instance: String) {
val messageStr = String(message)
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
scope.launch {
try {
parseMessage(messageStr)?.let {
receiveIfNew(it)
}
} catch (e: Exception) {
Log.d(TAG, "Message could not be parsed: ${e.message}")
}
}
}
private suspend fun parseMessage(message: String): GiftWrapEvent? {
(Event.fromJson(message) as? GiftWrapEvent)?.let {
return it
}
return null
}
private suspend fun receiveIfNew(event: GiftWrapEvent) {
if (eventCache.get(event.id) == null) {
eventCache.put(event.id, event.id)
EventNotificationConsumer(appContext).consume(event)
}
}
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
val sanitizedEndpoint = endpoint.dropLast(5)
pushHandler.setEndpoint(sanitizedEndpoint)
scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext)
}
}
override fun onReceive(context: Context, intent: Intent) {
val intentData = intent.dataString
val intentAction = intent.action.toString()
Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction")
super.onReceive(context, intent)
}
override fun onRegistrationFailed(context: Context, instance: String) {
Log.d(TAG, "Registration failed for Instance: $instance")
scope.cancel()
pushHandler.forceRemoveDistributor(context)
}
override fun onUnregistered(context: Context, instance: String) {
val removedEndpoint = pushHandler.endpoint
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
Log.d(TAG, "App is unregistered. ")
pushHandler.forceRemoveDistributor(context)
pushHandler.removeEndpoint()
}
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(appContext, NotificationManager::class.java) as NotificationManager
}
}

View File

@@ -1,9 +1,21 @@
package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo
object PushNotificationUtils {
var hasInit: Boolean = true
var hasInit: Boolean = false
private val pushHandler = PushDistributorHandler
suspend fun init(accounts: List<AccountInfo>) {
if (hasInit || pushHandler.savedDistributorExists()) {
return
}
try {
if (pushHandler.savedDistributorExists()) {
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
}
} catch (e: Exception) {
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
}
}
}

View File

@@ -0,0 +1,188 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.Material3RichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNotificationPermission
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val notificationPermissionState = CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
if (notificationPermissionState.status.isGranted) {
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
val context = LocalContext.current
var distributorPresent by remember {
mutableStateOf(PushDistributorHandler.savedDistributorExists())
}
if (!distributorPresent) {
LoadDistributors() { currentDistributor, list, readableListWithExplainer ->
if (readableListWithExplainer.size > 1) {
SpinnerSelectionDialog(
title = stringResource(id = R.string.select_push_server),
options = readableListWithExplainer,
onSelect = { index ->
if (list[index] == "None") {
PushDistributorHandler.forceRemoveDistributor(context)
sharedPreferencesViewModel.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector()
} else {
val fullDistributorName = list[index]
PushDistributorHandler.saveDistributor(fullDistributorName)
}
distributorPresent = true
Log.d("Amethyst", "NotificationScreen: Distributor registered.")
},
onDismiss = {
distributorPresent = true
Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.")
}
)
} else {
AlertDialog(
onDismissRequest = {
distributorPresent = true
},
title = {
Text(stringResource(R.string.push_server_install_app))
},
text = {
Material3RichText(
style = RichTextStyle().resolveDefaults()
) {
Markdown(
content = stringResource(R.string.push_server_install_app_description)
)
}
},
confirmButton = {
Row(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {
distributorPresent = true
sharedPreferencesViewModel.dontShowPushNotificationSelector()
}) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
}
Button(onClick = {
distributorPresent = true
}, contentPadding = PaddingValues(horizontal = 16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_button_ok))
}
}
}
}
)
}
}
} else {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
}
}
}
}
@Composable
fun LoadDistributors(
onInner: @Composable (String, ImmutableList<String>, ImmutableList<TitleExplainer>) -> Unit
) {
val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None"
val list = remember {
PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList()
}
val readableListWithExplainer =
PushDistributorHandler.formattedDistributorNames()
.mapIndexed { index, name ->
TitleExplainer(
name,
stringResource(id = R.string.push_server_uses_app_explainer, list[index])
)
}
.plus(
TitleExplainer(
stringResource(id = R.string.push_server_none),
stringResource(id = R.string.push_server_none_explainer)
)
)
.toImmutableList()
onInner(
currentDistributor,
list,
readableListWithExplainer
)
}
@Composable
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val context = LocalContext.current
LoadDistributors() { currentDistributor, list, readableListWithExplainer ->
SettingsRow(
R.string.push_server_title,
R.string.push_server_explainer,
selectedItens = readableListWithExplainer,
selectedIndex = list.indexOf(currentDistributor)
) { index ->
if (list[index] == "None") {
sharedPreferencesViewModel.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])
}
}
}
}

View File

@@ -108,7 +108,7 @@
tools:replace="screenOrientation" />
<service
android:name=".PlaybackService"
android:name=".service.playback.PlaybackService"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="true"
android:exported="true">

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3.pullrefresh
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.inspectable
import androidx.compose.ui.unit.Velocity
/**
* A nested scroll modifier that provides scroll events to [state].
*
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
* order to receive scroll events. For example:
*
* @sample androidx.compose.material.samples.PullRefreshSample
*
* @param state The [PullRefreshState] associated with this pull-to-refresh component.
* The state will be updated by this modifier.
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored.
*/
// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple.
fun Modifier.pullRefresh(
state: PullRefreshState,
enabled: Boolean = true
) = inspectable(
inspectorInfo = debugInspectorInfo {
name = "pullRefresh"
properties["state"] = state
properties["enabled"] = enabled
}
) {
Modifier.pullRefresh(state::onPull, state::onRelease, enabled)
}
/**
* A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom
* pull refresh components.
*
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
* order to receive scroll events. For example:
*
* @sample androidx.compose.material.samples.CustomPullRefreshSample
*
* @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument.
* Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling
* down despite being at the top of a scrollable component), whereas negative delta (swiping up) is
* dispatched first (in case it is needed to push the indicator back up), and then the unconsumed
* delta is passed on to the child. The callback returns how much delta was consumed.
* @param onRelease Callback for when drag is released, takes float flingVelocity as argument.
* The callback returns how much velocity was consumed - in most cases this should only consume
* velocity if pull refresh has been dragged already and the velocity is positive (the fling is
* downwards), as an upwards fling should typically still scroll a scrollable component beneath the
* pullRefresh. This is invoked before any remaining velocity is passed to the child.
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither
* [onPull] nor [onRelease] will be invoked.
*/
fun Modifier.pullRefresh(
onPull: (pullDelta: Float) -> Float,
onRelease: suspend (flingVelocity: Float) -> Float,
enabled: Boolean = true
) = inspectable(
inspectorInfo = debugInspectorInfo {
name = "pullRefresh"
properties["onPull"] = onPull
properties["onRelease"] = onRelease
properties["enabled"] = enabled
}
) {
Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
}
private class PullRefreshNestedScrollConnection(
private val onPull: (pullDelta: Float) -> Float,
private val onRelease: suspend (flingVelocity: Float) -> Float,
private val enabled: Boolean
) : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = when {
!enabled -> Offset.Zero
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = when {
!enabled -> Offset.Zero
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))
}
}

View File

@@ -0,0 +1,238 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3.pullrefresh
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout.
*
* @sample androidx.compose.material.samples.PullRefreshSample
*
* @param refreshing A boolean representing whether a refresh is occurring.
* @param state The [PullRefreshState] which controls where and how the indicator will be drawn.
* @param modifier Modifiers for the indicator.
* @param backgroundColor The color of the indicator's background.
* @param contentColor The color of the indicator's arc and arrow.
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
*/
// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to
// enable people to use this indicator with custom pull-to-refresh components.
@Composable
fun PullRefreshIndicator(
refreshing: Boolean,
state: PullRefreshState,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor),
scale: Boolean = false
) {
val showElevation by remember(refreshing, state) {
derivedStateOf { refreshing || state.position > 0.5f }
}
Surface(
modifier = modifier
.size(IndicatorSize)
.pullRefreshIndicatorTransform(state, scale),
shape = SpinnerShape,
color = backgroundColor,
shadowElevation = if (showElevation) Elevation else 0.dp
) {
Crossfade(
targetState = refreshing,
animationSpec = tween(durationMillis = CrossfadeDurationMs)
) { refreshing ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val spinnerSize = (ArcRadius + StrokeWidth).times(2)
if (refreshing) {
CircularProgressIndicator(
color = contentColor,
strokeWidth = StrokeWidth,
modifier = Modifier.size(spinnerSize)
)
} else {
CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))
}
}
}
}
}
/**
* Modifier.size MUST be specified.
*/
@Composable
private fun CircularArrowIndicator(
state: PullRefreshState,
color: Color,
modifier: Modifier
) {
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
val targetAlpha by remember(state) {
derivedStateOf {
if (state.progress >= 1f) MaxAlpha else MinAlpha
}
}
val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)
// Empty semantics for tests
Canvas(modifier.semantics {}) {
val values = ArrowValues(state.progress)
val alpha = alphaState.value
rotate(degrees = values.rotation) {
val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f
val arcBounds = Rect(
size.center.x - arcRadius,
size.center.y - arcRadius,
size.center.x + arcRadius,
size.center.y + arcRadius
)
drawArc(
color = color,
alpha = alpha,
startAngle = values.startAngle,
sweepAngle = values.endAngle - values.startAngle,
useCenter = false,
topLeft = arcBounds.topLeft,
size = arcBounds.size,
style = Stroke(
width = StrokeWidth.toPx(),
cap = StrokeCap.Square
)
)
drawArrow(path, arcBounds, color, alpha, values)
}
}
}
@Immutable
private class ArrowValues(
val rotation: Float,
val startAngle: Float,
val endAngle: Float,
val scale: Float
)
private fun ArrowValues(progress: Float): ArrowValues {
// Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%.
val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// Calculations based on SwipeRefreshLayout specification.
val endTrim = adjustedPercent * MaxProgressArc
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f
val startAngle = rotation * 360
val endAngle = (rotation + endTrim) * 360
val scale = min(1f, adjustedPercent)
return ArrowValues(rotation, startAngle, endAngle, scale)
}
private fun DrawScope.drawArrow(
arrow: Path,
bounds: Rect,
color: Color,
alpha: Float,
values: ArrowValues
) {
arrow.reset()
arrow.moveTo(0f, 0f) // Move to left corner
arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner
// Line to tip of arrow
arrow.lineTo(
x = ArrowWidth.toPx() * values.scale / 2,
y = ArrowHeight.toPx() * values.scale
)
val radius = min(bounds.width, bounds.height) / 2f
val inset = ArrowWidth.toPx() * values.scale / 2f
arrow.translate(
Offset(
x = radius + bounds.center.x - inset,
y = bounds.center.y + StrokeWidth.toPx() / 2f
)
)
arrow.close()
rotate(degrees = values.endAngle) {
drawPath(path = arrow, color = color, alpha = alpha)
}
}
private const val CrossfadeDurationMs = 100
private const val MaxProgressArc = 0.8f
private val IndicatorSize = 40.dp
private val SpinnerShape = CircleShape
private val ArcRadius = 7.5.dp
private val StrokeWidth = 2.5.dp
private val ArrowWidth = 10.dp
private val ArrowHeight = 5.dp
private val Elevation = 6.dp
// Values taken from SwipeRefreshLayout
private const val MinAlpha = 0.3f
private const val MaxAlpha = 1f
private val AlphaTween = tween<Float>(300, easing = LinearEasing)

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3.pullrefresh
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.inspectable
/**
* A modifier for translating the position and scaling the size of a pull-to-refresh indicator
* based on the given [PullRefreshState].
*
* @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample
*
* @param state The [PullRefreshState] which determines the position of the indicator.
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
*/
// TODO: Consider whether the state parameter should be replaced with lambdas.
fun Modifier.pullRefreshIndicatorTransform(
state: PullRefreshState,
scale: Boolean = false
) = inspectable(
inspectorInfo = debugInspectorInfo {
name = "pullRefreshIndicatorTransform"
properties["state"] = state
properties["scale"] = scale
}
) {
Modifier
// Essentially we only want to clip the at the top, so the indicator will not appear when
// the position is 0. It is preferable to clip the indicator as opposed to the layout that
// contains the indicator, as this would also end up clipping shadows drawn by items in a
// list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE
// for the other dimensions to allow for more room for elevation / arbitrary indicators - we
// only ever really want to clip at the top edge.
.drawWithContent {
clipRect(
top = 0f,
left = -Float.MAX_VALUE,
right = Float.MAX_VALUE,
bottom = Float.MAX_VALUE
) {
this@drawWithContent.drawContent()
}
}
.graphicsLayer {
translationY = state.position - size.height
if (scale && !state.refreshing) {
val scaleFraction = LinearOutSlowInEasing
.transform(state.position / state.threshold)
.coerceIn(0f, 1f)
scaleX = scaleFraction
scaleY = scaleFraction
}
}
}

View File

@@ -0,0 +1,228 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3.pullrefresh
import androidx.compose.animation.core.animate
import androidx.compose.foundation.MutatorMutex
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.pow
/**
* Creates a [PullRefreshState] that is remembered across compositions.
*
* Changes to [refreshing] will result in [PullRefreshState] being updated.
*
* @sample androidx.compose.material.samples.PullRefreshSample
*
* @param refreshing A boolean representing whether a refresh is currently occurring.
* @param onRefresh The function to be called to trigger a refresh.
* @param refreshThreshold The threshold below which, if a release
* occurs, [onRefresh] will be called.
* @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This
* offset corresponds to the position of the bottom of the indicator.
*/
@Composable
fun rememberPullRefreshState(
refreshing: Boolean,
onRefresh: () -> Unit,
refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset
): PullRefreshState {
require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
val scope = rememberCoroutineScope()
val onRefreshState = rememberUpdatedState(onRefresh)
val thresholdPx: Float
val refreshingOffsetPx: Float
with(LocalDensity.current) {
thresholdPx = refreshThreshold.toPx()
refreshingOffsetPx = refreshingOffset.toPx()
}
val state = remember(scope) {
PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx)
}
SideEffect {
state.setRefreshing(refreshing)
state.setThreshold(thresholdPx)
state.setRefreshingOffset(refreshingOffsetPx)
}
return state
}
/**
* A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh
* behaviour to a scroll component. Based on Android's SwipeRefreshLayout.
*
* Provides [progress], a float representing how far the user has pulled as a percentage of the
* refreshThreshold. Values of one or less indicate that the user has not yet pulled past the
* threshold. Values greater than one indicate how far past the threshold the user has pulled.
*
* Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like
* pull-to-refresh behaviour with a custom indicator.
*
* Should be created using [rememberPullRefreshState].
*/
class PullRefreshState internal constructor(
private val animationScope: CoroutineScope,
private val onRefreshState: State<() -> Unit>,
refreshingOffset: Float,
threshold: Float
) {
/**
* A float representing how far the user has pulled as a percentage of the refreshThreshold.
*
* If the component has not been pulled at all, progress is zero. If the pull has reached
* halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has
* gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to
* two times the refreshThreshold.
*/
val progress get() = adjustedDistancePulled / threshold
internal val refreshing get() = _refreshing
internal val position get() = _position
internal val threshold get() = _threshold
private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier }
private var _refreshing by mutableStateOf(false)
private var _position by mutableStateOf(0f)
private var distancePulled by mutableStateOf(0f)
private var _threshold by mutableStateOf(threshold)
private var _refreshingOffset by mutableStateOf(refreshingOffset)
internal fun onPull(pullDelta: Float): Float {
if (_refreshing) return 0f // Already refreshing, do nothing.
val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
_position = calculateIndicatorPosition()
return dragConsumed
}
internal fun onRelease(velocity: Float): Float {
if (refreshing) return 0f // Already refreshing, do nothing
if (adjustedDistancePulled > threshold) {
onRefreshState.value()
}
animateIndicatorTo(0f)
val consumed = when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
internal fun setRefreshing(refreshing: Boolean) {
if (_refreshing != refreshing) {
_refreshing = refreshing
distancePulled = 0f
animateIndicatorTo(if (refreshing) _refreshingOffset else 0f)
}
}
internal fun setThreshold(threshold: Float) {
_threshold = threshold
}
internal fun setRefreshingOffset(refreshingOffset: Float) {
if (_refreshingOffset != refreshingOffset) {
_refreshingOffset = refreshingOffset
if (refreshing) animateIndicatorTo(refreshingOffset)
}
}
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
// overhead of running through the animation pipeline instead of directly mutating the state.
private val mutatorMutex = MutatorMutex()
private fun animateIndicatorTo(offset: Float) = animationScope.launch {
mutatorMutex.mutate {
animate(initialValue = _position, targetValue = offset) { value, _ ->
_position = value
}
}
}
private fun calculateIndicatorPosition(): Float = when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= threshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = threshold * tensionPercent
threshold + extraOffset
}
}
}
/**
* Default parameter values for [rememberPullRefreshState].
*/
object PullRefreshDefaults {
/**
* If the indicator is below this threshold offset when it is released, a refresh
* will be triggered.
*/
val RefreshThreshold = 80.dp
/**
* The offset at which the indicator should be rendered whilst a refresh is occurring.
*/
val RefreshingOffset = 56.dp
}
/**
* The distance pulled is multiplied by this value to give us the adjusted distance pulled, which
* is used in calculating the indicator position (when the adjusted distance pulled is less than
* the refresh threshold, it is the indicator position, otherwise the indicator position is
* derived from the progress).
*/
private const val DragMultiplier = 0.5f

View File

@@ -1,18 +1,49 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.Context
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import android.util.Log
import coil.ImageLoader
import coil.disk.DiskCache
import com.vitorpamplona.amethyst.service.playback.VideoCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.io.File
import kotlin.time.measureTimedValue
class Amethyst : Application() {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onTerminate() {
super.onTerminate()
applicationIOScope.cancel()
}
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
newCache.initFileCache(this)
newCache
}
private val imageCache: DiskCache by lazy {
DiskCache.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.build()
}
override fun onCreate() {
super.onCreate()
instance = this
VideoCache.initFileCache(instance)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
@@ -22,17 +53,23 @@ class Amethyst : Application() {
)
StrictMode.setVmPolicy(
VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectContentUriWithoutPermission()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
.detectAll()
.penaltyLog()
// .penaltyDeath()
.build()
)
}
GlobalScope.launch(Dispatchers.IO) {
val (value, elapsed) = measureTimedValue {
// initializes the video cache in a thread
videoCache
}
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
}
}
fun imageLoaderBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(applicationContext).diskCache { imageCache }
}
companion object {
@@ -40,3 +77,9 @@ class Amethyst : Application() {
private set
}
}
internal val Context.safeCacheDir: File
get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}

View File

@@ -6,6 +6,7 @@ import androidx.security.crypto.MasterKey
object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
}

View File

@@ -7,6 +7,7 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
@@ -14,16 +15,29 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
@@ -45,9 +59,6 @@ private object PrefKeys {
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val FOLLOWING_CHANNELS = "following_channels"
const val FOLLOWING_COMMUNITIES = "following_communities"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LANGUAGE_PREFS = "languagePreferences"
@@ -76,22 +87,30 @@ private object PrefKeys {
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture"
const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
}
object LocalPreferences {
private const val comma = ","
private var _currentAccount: String? = null
private var _savedAccounts: List<AccountInfo>? = null
private var _cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
fun currentAccount(): String? {
suspend fun currentAccount(): String? {
if (_currentAccount == null) {
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
return _currentAccount
}
private fun updateCurrentAccount(npub: String) {
private suspend fun updateCurrentAccount(npub: String) {
if (_currentAccount != npub) {
_currentAccount = npub
@@ -101,22 +120,39 @@ object LocalPreferences {
}
}
private var _savedAccounts: List<String>? = null
private fun savedAccounts(): List<String> {
private fun savedAccounts(): List<AccountInfo> {
if (_savedAccounts == null) {
_savedAccounts = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
with(encryptedPreferences()) {
val newSystemOfAccounts = getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
Event.mapper.readValue<List<AccountInfo>>(it)
}
if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) {
_savedAccounts = newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
val migrated = oldAccounts.map { npub ->
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank()
)
}
_savedAccounts = migrated
}
}
}
return _savedAccounts!!
}
private fun updateSavedAccounts(accounts: List<String>) {
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) = withContext(Dispatchers.IO) {
if (_savedAccounts != accounts) {
_savedAccounts = accounts
encryptedPreferences().edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts))
}.apply()
}
}
@@ -124,38 +160,40 @@ object LocalPreferences {
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
private fun addAccount(npub: String) {
val accounts = savedAccounts().toMutableList()
if (npub !in accounts) {
accounts.add(npub)
updateSavedAccounts(accounts)
}
private suspend fun addAccount(accInfo: AccountInfo) {
val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo)
updateSavedAccounts(accounts)
}
private fun setCurrentAccount(account: Account) {
private suspend fun setCurrentAccount(account: Account) = withContext(Dispatchers.IO) {
val npub = account.userProfile().pubkeyNpub()
val accInfo = AccountInfo(
npub,
account.isWriteable(),
account.signer is NostrSignerExternal
)
updateCurrentAccount(npub)
addAccount(npub)
addAccount(accInfo)
}
fun switchToAccount(npub: String) {
updateCurrentAccount(npub)
suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
updateCurrentAccount(accountInfo.npub)
}
/**
* Removes the account from the app level shared preferences
*/
private fun removeAccount(npub: String) {
val accounts = savedAccounts().toMutableList()
if (accounts.remove(npub)) {
updateSavedAccounts(accounts)
}
private suspend fun removeAccount(accountInfo: AccountInfo) {
val accounts = savedAccounts().filter { it.npub != accountInfo.npub }
updateSavedAccounts(accounts)
}
/**
* Deletes the npub-specific shared preference file
*/
private fun deleteUserPreferenceFile(npub: String) {
checkNotInMainThread()
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
@@ -165,6 +203,8 @@ object LocalPreferences {
}
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
checkNotInMainThread()
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
@@ -182,47 +222,41 @@ object LocalPreferences {
* condition and the file will probably not be deleted
*/
@SuppressLint("ApplySharedPref")
fun updatePrefsForLogout(npub: String) {
val userPrefs = encryptedPreferences(npub)
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
val userPrefs = encryptedPreferences(accountInfo.npub)
userPrefs.edit().clear().commit()
removeAccount(npub)
deleteUserPreferenceFile(npub)
removeAccount(accountInfo)
deleteUserPreferenceFile(accountInfo.npub)
if (savedAccounts().isEmpty()) {
val appPrefs = encryptedPreferences()
appPrefs.edit().clear().apply()
} else if (currentAccount() == npub) {
updateCurrentAccount(savedAccounts().elementAt(0))
encryptedPreferences().edit().clear().apply()
} else if (currentAccount() == accountInfo.npub) {
updateCurrentAccount(savedAccounts().elementAt(0).npub)
}
}
fun updatePrefsForLogin(account: Account) {
suspend fun updatePrefsForLogin(account: Account) {
setCurrentAccount(account)
saveToEncryptedStorage(account)
}
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts().map { npub ->
AccountInfo(
npub,
hasPrivKey(npub),
getLoggedInWithExternalSigner(npub)
)
}
return savedAccounts()
}
fun allLocalAccountNPubs(): Set<String> {
return savedAccounts().toSet()
}
suspend fun saveToEncryptedStorage(account: Account) = withContext(Dispatchers.IO) {
checkNotInMainThread()
fun saveToEncryptedStorage(account: Account) {
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal)
if (account.signer is NostrSignerExternal) {
remove(PrefKeys.NOSTR_PRIVKEY)
putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName)
} else {
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
}
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putString(PrefKeys.LANGUAGE_PREFS, Event.mapper.writeValueAsString(account.languagePreferences))
@@ -230,11 +264,11 @@ object LocalPreferences {
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
putString(PrefKeys.DEFAULT_FILE_SERVER, Event.mapper.writeValueAsString(account.defaultFileServer))
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList.value)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, Event.mapper.writeValueAsString(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.mapper.writeValueAsString(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -251,66 +285,124 @@ object LocalPreferences {
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
}.apply()
val globalPrefs = encryptedPreferences()
globalPrefs.edit().apply {
if (account.settings.automaticallyShowImages.prefCode == null) {
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages.prefCode!!)
}
if (account.settings.automaticallyStartPlayback.prefCode == null) {
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback.prefCode!!)
}
if (account.settings.automaticallyShowUrlPreview.prefCode == null) {
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview.prefCode!!)
}
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
}.apply()
}
fun updateTheme(theme: Int) {
encryptedPreferences().edit().apply {
putInt(PrefKeys.THEME, theme)
}.apply()
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
return currentAccount()?.let {
loadCurrentAccountFromEncryptedStorage(it)
}
}
fun getTheme(): Int {
return encryptedPreferences().getInt(PrefKeys.THEME, 0)
suspend fun migrateOldSharedSettings(): Settings? {
val prefs = encryptedPreferences()
loadOldSharedSettings(prefs)?.let {
saveSharedSettings(it, prefs)
return it
}
return null
}
fun getPreferredLanguage(): String {
return encryptedPreferences().getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
suspend fun saveSharedSettings(sharedSettings: Settings, prefs: SharedPreferences = encryptedPreferences()) {
with(prefs.edit()) {
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
apply()
}
}
private fun getLoggedInWithExternalSigner(npub: String): Boolean {
return encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
with(prefs) {
return try {
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let {
Event.mapper.readValue<Settings>(it)
}
} catch (e: Throwable) {
Log.w("LocalPreferences", "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", e)
e.printStackTrace()
null
}
}
}
private fun hasPrivKey(npub: String): Boolean {
return (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank()
@Deprecated("Turned into a single JSON object")
suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
with(prefs) {
if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
return null
}
val automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
} else {
ConnectivityType.ALWAYS
}
val automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
} else {
BooleanType.ALWAYS
}
val automaticallyShowProfilePictures = if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
} else {
ConnectivityType.ALWAYS
}
val themeType = if (contains(PrefKeys.THEME)) {
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
} else {
ThemeType.SYSTEM
}
return Settings(
themeType,
getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null },
automaticallyShowImages,
automaticallyStartPlayback,
automaticallyShowUrlPreview,
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
false,
false
)
}
}
fun loadFromEncryptedStorage(): Account? {
val acc = loadFromEncryptedStorage(currentAccount())
acc?.registerObservers()
return acc
val mutex = Mutex()
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = withContext(Dispatchers.IO) {
mutex.withLock {
if (_cachedAccounts.containsKey(npub)) {
return@withContext _cachedAccounts.get(npub)
}
val account = innerLoadCurrentAccountFromEncryptedStorage(npub)
account?.registerObservers()
_cachedAccounts.put(npub, account)
return@withContext account
}
}
fun loadFromEncryptedStorage(npub: String?): Account? {
encryptedPreferences(npub).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
checkNotInMainThread()
return@withContext with(encryptedPreferences(npub)) {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
println("LocalRelays: $it")
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
@@ -335,9 +427,15 @@ object LocalPreferences {
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
ServersAvailable.values().firstOrNull() { it.name == serverName }
} ?: ServersAvailable.NOSTR_BUILD
val defaultFileServer = try {
getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
} ?: Nip96MediaServers.DEFAULT[0]
} catch (e: Exception) {
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
e.printStackTrace()
Nip96MediaServers.DEFAULT[0]
}
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
@@ -379,7 +477,6 @@ object LocalPreferences {
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
@@ -399,33 +496,17 @@ object LocalPreferences {
mapOf()
}
val settings = Settings()
encryptedPreferences().apply {
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
} else {
ConnectivityType.ALWAYS
}
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
} else {
ConnectivityType.ALWAYS
}
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
} else {
ConnectivityType.ALWAYS
}
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
val signer = if (loginWithExternalSigner) {
val packageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner"
NostrSignerExternal(pubKey, ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName))
} else {
NostrSignerInternal(keyPair)
}
val a = Account(
keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels,
followingCommunities = followingCommunities,
hiddenUsers = hiddenUsers,
val account = Account(
keyPair = keyPair,
signer = signer,
localRelays = localRelays,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
@@ -434,10 +515,10 @@ object LocalPreferences {
reactionChoices = reactionChoices,
defaultZapType = defaultZapType,
defaultFileServer = defaultFileServer,
defaultHomeFollowList = defaultHomeFollowList,
defaultStoriesFollowList = defaultStoriesFollowList,
defaultNotificationFollowList = defaultNotificationFollowList,
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
@@ -448,12 +529,18 @@ object LocalPreferences {
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
settings = settings,
loginWithExternalSigner = loginWithExternalSigner
lastReadPerRoute = lastReadPerRoute
)
return a
// Loads from DB
account.userProfile()
withContext(Dispatchers.Main) {
// Loads Live Objects
account.userProfile().live()
}
return@with account
}
}
}

View File

@@ -1,18 +1,15 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.compose.runtime.Stable
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.util.DebugLogger
import coil.size.Precision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
@@ -31,23 +28,33 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import java.io.File
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
object ServiceManager {
var shouldPauseService: Boolean = true // to not open amber in a loop trying to use auth relays and registering for notifications
@Stable
class ServiceManager {
private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications
private var account: Account? = null
fun start(account: Account, context: Context) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var collectorJob: Job? = null
private fun start(account: Account) {
this.account = account
ExternalSignerUtils.account = account
start(context)
start()
}
@Synchronized
fun start(context: Context) {
private fun start() {
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
if (isStarted && account != null) {
return
}
@@ -56,40 +63,62 @@ object ServiceManager {
val myAccount = account
// Resets Proxy Use
HttpClient.start(account)
HttpClient.start(account?.proxy)
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
Coil.setImageLoader {
ImageLoader.Builder(context).components {
Amethyst.instance.imageLoaderBuilder().components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}.logger(DebugLogger())
.diskCache { SingletonDiskCache.get(context.applicationContext) }
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.precision(Precision.INEXACT)
.respectCacheHeaders(false)
.build()
}
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
collectorJob?.cancel()
collectorJob = null
collectorJob = scope.launch {
myAccount.userProfile().flow().relays.stateFlow.collect {
if (isStarted) {
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Client.reconnect(newRelaySet, onlyIfChanged = true)
}
}
}
// start services
NostrAccountDataSource.account = myAccount
NostrAccountDataSource.otherAccounts = LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
null
}
}
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
ImageUploader.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
NostrVideoDataSource.start()
GlobalScope.launch(Dispatchers.IO) {
delay(3000)
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
NostrVideoDataSource.start()
}
// More Info Data Sources
NostrSingleEventDataSource.start()
@@ -99,28 +128,31 @@ object ServiceManager {
}
}
fun pause() {
private fun pause() {
Log.d("ServiceManager", "Pausing Relay Services")
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomDataSource.stop()
NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.stop()
collectorJob?.cancel()
collectorJob = null
NostrCommunityDataSource.stop()
NostrHashtagDataSource.stop()
NostrGeohashDataSource.stop()
NostrSearchEventOrUserDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
NostrVideoDataSource.stop()
NostrAccountDataSource.stopSync()
NostrHomeDataSource.stopSync()
NostrChannelDataSource.stopSync()
NostrChatroomDataSource.stopSync()
NostrChatroomListDataSource.stopSync()
NostrDiscoveryDataSource.stopSync()
Client.disconnect()
NostrCommunityDataSource.stopSync()
NostrHashtagDataSource.stopSync()
NostrGeohashDataSource.stopSync()
NostrSearchEventOrUserDataSource.stopSync()
NostrSingleChannelDataSource.stopSync()
NostrSingleEventDataSource.stopSync()
NostrSingleUserDataSource.stopSync()
NostrThreadDataSource.stopSync()
NostrUserProfileDataSource.stopSync()
NostrVideoDataSource.stopSync()
Client.reconnect(null)
isStarted = false
}
@@ -131,10 +163,14 @@ object ServiceManager {
fun trimMemory() {
LocalCache.cleanObservers()
val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet()
val accounts = LocalPreferences.allSavedAccounts().mapNotNull {
decodePublicKeyAsHexOrNull(it.npub)
}.toSet()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
NostrChatroomDataSource.clearEOSEs(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
@@ -142,29 +178,44 @@ object ServiceManager {
LocalCache.pruneExpiredEvents()
}
}
}
object SingletonDiskCache {
private const val DIRECTORY = "image_cache"
private var instance: DiskCache? = null
// This method keeps the pause/start in a Syncronized block to
// avoid concurrent pauses and starts.
@Synchronized
fun get(context: Context): DiskCache {
return instance ?: run {
// Create the singleton disk cache instance.
DiskCache.Builder()
.directory(context.safeCacheDir.resolve(DIRECTORY))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.build()
.also { instance = it }
fun forceRestart(account: Account? = null, start: Boolean = true, pause: Boolean = true) {
if (pause) {
pause()
}
if (start) {
if (account != null) {
start(account)
} else {
start()
}
}
}
}
internal val Context.safeCacheDir: File
get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
fun restartIfDifferentAccount(account: Account) {
if (this.account != account) {
forceRestart(account, true, true)
}
}
fun forceRestart() {
forceRestart(null, true, true)
}
fun justStart() {
forceRestart(null, true, false)
}
fun pauseForGood() {
forceRestart(null, false, true)
}
fun pauseForGoodAndClearAccount() {
account = null
forceRestart(null, false, true)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,8 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {

View File

@@ -8,7 +8,7 @@ import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches", "thenostr" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))

View File

@@ -3,8 +3,6 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -13,15 +11,73 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
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.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.BookmarkListEvent
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarEvent
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelHideMessageEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NNSEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RecommendRelayEvent
import com.vitorpamplona.quartz.events.RelaySetEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.io.File
@@ -67,7 +123,7 @@ object LocalCache {
fun getUserIfExists(key: String): User? {
if (key.isEmpty()) return null
return users.get(key)
return users[key]
}
fun getAddressableNoteIfExists(key: String): AddressableNote? {
@@ -93,7 +149,15 @@ object LocalCache {
val noteEvent = note.event
return if (noteEvent is AddressableEvent) {
// upgrade to the latest
checkGetOrCreateAddressableNote(noteEvent.address().toTag())
val newNote = checkGetOrCreateAddressableNote(noteEvent.address().toTag())
if (newNote != null && noteEvent is Event && newNote.event == null) {
val author = note.author ?: getOrCreateUser(noteEvent.pubKey)
newNote.loadEvent(noteEvent as Event, author, emptyList())
note.moveAllReferencesTo(newNote)
}
newNote
} else {
note
}
@@ -101,6 +165,18 @@ object LocalCache {
return null
}
fun getOrAddAliasNote(idHex: String, note: Note): Note {
checkNotInMainThread()
return notes.get(idHex) ?: run {
require(isValidHex(idHex)) {
"$idHex is not a valid hex"
}
notes.putIfAbsent(idHex, note) ?: note
}
}
fun getOrCreateNote(idHex: String): Note {
checkNotInMainThread()
@@ -201,7 +277,7 @@ object LocalCache {
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
}
}
@@ -209,14 +285,6 @@ object LocalCache {
val user = getOrCreateUser(event.pubKey)
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
if (event.dTag() == "bookmark") {
val loggedInUser = LocalPreferences.currentAccount()
val hexKey = loggedInUser?.bechToBytes()
if (hexKey != null) {
val pubKey = Hex.encode(hexKey)
if (pubKey == event.pubKey) {
ExternalSignerUtils.content.remove(event.id)
}
}
user.updateBookmark(event)
}
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
@@ -348,21 +416,28 @@ object LocalCache {
val channel = getOrCreateChannel(note.idHex) {
LiveActivitiesChannel(note.address)
} as? LiveActivitiesChannel
channel?.updateChannelInfo(author, event, event.createdAt)
val creator = event.host()?.ifBlank { null }?.let {
checkGetOrCreateUser(it)
} ?: author
channel?.updateChannelInfo(creator, event, event.createdAt)
refreshObservers(note)
}
}
fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
fun consume(event: EmojiPackSelectionEvent) { consumeBaseReplaceable(event) }
private fun consume(event: EmojiPackEvent) { consumeBaseReplaceable(event) }
private fun consume(event: ClassifiedsEvent) { consumeBaseReplaceable(event) }
private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) }
private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) }
fun consume(event: MuteListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: FileServersEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: PeopleListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: AdvertisedRelayListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: EmojiPackSelectionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: EmojiPackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: ClassifiedsEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: PinListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: RelaySetEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: AudioTrackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: StatusEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -385,7 +460,7 @@ object LocalCache {
}
}
fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
fun consume(event: BadgeDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: BadgeProfilesEvent) {
val version = getOrCreateNote(event.id)
@@ -431,14 +506,14 @@ object LocalCache {
refreshObservers(note)
}
private fun comsume(event: NNSEvent) { consumeBaseReplaceable(event) }
fun consume(event: AppDefinitionEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarDateSlotEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarTimeSlotEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarRSVPEvent) { consumeBaseReplaceable(event) }
private fun comsume(event: NNSEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: AppDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CalendarEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CalendarDateSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CalendarTimeSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CalendarRSVPEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consumeBaseReplaceable(event: BaseAddressableEvent) {
private fun consumeBaseReplaceable(event: BaseAddressableEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -448,6 +523,11 @@ object LocalCache {
version.moveAllReferencesTo(note)
}
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -458,7 +538,7 @@ object LocalCache {
}
}
fun consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
fun consume(event: AppRecommendationEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
@@ -625,7 +705,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -703,7 +783,7 @@ object LocalCache {
fun consume(event: ChannelMetadataEvent) {
val channelId = event.channel()
// Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
// Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}")
if (channelId.isNullOrBlank()) return
// new event
@@ -711,10 +791,8 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -1207,7 +1285,7 @@ object LocalCache {
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
removeFromCache(childrenToBeRemoved)
if (toBeRemoved.size > 100 || it.value.notes.size > 100) {
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
@@ -1226,7 +1304,7 @@ object LocalCache {
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
removeFromCache(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept")
@@ -1257,7 +1335,7 @@ object LocalCache {
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
removeFromCache(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
@@ -1286,7 +1364,7 @@ object LocalCache {
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
removeFromCache(childrenToBeRemoved)
toBeRemoved.forEach {
it.replyTo?.forEach { masterNote ->
@@ -1309,22 +1387,24 @@ object LocalCache {
masterNote.clearEOSE() // allows reloading of these events if needed
}
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
val noteEvent = note.event
if (noteEvent is LnZapEvent) {
noteEvent.zappedAuthor().forEach {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
if (noteEvent is LnZapRequestEvent) {
noteEvent.zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull {
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().mapNotNull {
val author = getUserIfExists(it.key)
author?.removeReport(note)
author?.clearEOSE()
@@ -1334,31 +1414,7 @@ object LocalCache {
notes.remove(note.idHex)
}
fun removeAuthorLinkTo(note: Note) {
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull {
val author = getUserIfExists(it.key)
author?.removeReport(note)
author?.clearEOSE()
}
}
}
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
fun removeFromCache(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note ->
removeFromCache(note)
}
@@ -1378,7 +1434,7 @@ object LocalCache {
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
removeFromCache(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
@@ -1390,7 +1446,7 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
val toBeRemoved = account.hiddenUsers.map { userHex ->
val toBeRemoved = account.liveHiddenUsers.value?.hiddenUsers?.map { userHex ->
(
notes.values.filter {
it.event?.pubKey() == userHex
@@ -1398,14 +1454,14 @@ object LocalCache {
it.event?.pubKey() == userHex
}
).toSet()
}.flatten()
}?.flatten() ?: emptyList()
toBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
removeFromCache(childrenToBeRemoved)
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
@@ -1447,7 +1503,7 @@ object LocalCache {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson())
}
false
} else {
@@ -1460,26 +1516,26 @@ object LocalCache {
try {
when (event) {
is AdvertisedRelayListEvent -> consume(event)
is AppDefinitionEvent -> consume(event)
is AppRecommendationEvent -> consume(event)
is AdvertisedRelayListEvent -> consume(event, relay)
is AppDefinitionEvent -> consume(event, relay)
is AppRecommendationEvent -> consume(event, relay)
is AudioHeaderEvent -> consume(event, relay)
is AudioTrackEvent -> consume(event)
is AudioTrackEvent -> consume(event, relay)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event, relay)
is BadgeProfilesEvent -> consume(event)
is BookmarkListEvent -> consume(event)
is CalendarEvent -> consume(event)
is CalendarDateSlotEvent -> consume(event)
is CalendarTimeSlotEvent -> consume(event)
is CalendarRSVPEvent -> consume(event)
is CalendarEvent -> consume(event, relay)
is CalendarDateSlotEvent -> consume(event, relay)
is CalendarTimeSlotEvent -> consume(event, relay)
is CalendarRSVPEvent -> consume(event, relay)
is ChannelCreateEvent -> consume(event)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ChatMessageEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event)
is ClassifiedsEvent -> consume(event, relay)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let {
@@ -1489,11 +1545,12 @@ object LocalCache {
}
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
is EmojiPackEvent -> consume(event)
is EmojiPackSelectionEvent -> consume(event)
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is FileServersEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is GiftWrapEvent -> consume(event, relay)
@@ -1512,14 +1569,15 @@ object LocalCache {
is LnZapPaymentResponseEvent -> consume(event)
is LongTextNoteEvent -> consume(event, relay)
is MetadataEvent -> consume(event)
is NNSEvent -> comsume(event)
is MuteListEvent -> consume(event, relay)
is NNSEvent -> comsume(event, relay)
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
is PeopleListEvent -> consume(event)
is PinListEvent -> consume(event, relay)
is PeopleListEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is RelaySetEvent -> consume(event)
is RelaySetEvent -> consume(event, relay)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let {
@@ -1548,7 +1606,7 @@ object LocalCache {
@Stable
class LocalCacheLiveData {
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
private val _newEventBundles = MutableSharedFlow<Set<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
// Refreshes observers in batches.

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
@@ -20,10 +21,29 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
@@ -68,10 +88,12 @@ open class Note(val idHex: String) {
private set
var zaps = mapOf<Note, Note?>()
private set
var zapsAmount: BigDecimal = BigDecimal.ZERO
var zapPayments = mapOf<Note, Note?>()
private set
var relays = listOf<String>()
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
@@ -88,13 +110,13 @@ open class Note(val idHex: String) {
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()
relays.firstOrNull()?.url
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
}
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
}
}
@@ -132,6 +154,7 @@ open class Note(val idHex: String) {
this.replyTo = replyTo
liveSet?.innerMetadata?.invalidateData()
flowSet?.metadata?.invalidateData()
}
}
@@ -247,7 +270,8 @@ open class Note(val idHex: String) {
reports = mapOf<User, List<Note>>()
zaps = mapOf<Note, Note?>()
zapPayments = mapOf<Note, Note?>()
relays = listOf<String>()
zapsAmount = BigDecimal.ZERO
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
liveSet?.innerReplies?.invalidateData()
@@ -260,7 +284,7 @@ open class Note(val idHex: String) {
}
fun removeReaction(note: Note) {
val tags = note.event?.tags() ?: emptyList()
val tags = note.event?.tags() ?: emptyArray()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
@@ -293,9 +317,11 @@ open class Note(val idHex: String) {
fun removeZap(note: Note) {
if (zaps[note] != null) {
zaps = zaps.minus(note)
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
} else if (zaps.containsValue(note)) {
zaps = zaps.filterValues { it != note }
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
}
}
@@ -336,11 +362,13 @@ open class Note(val idHex: String) {
if (zapRequest !in zaps.keys) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
}
} else if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
}
}
@@ -375,7 +403,7 @@ open class Note(val idHex: String) {
}
fun addReaction(note: Note) {
val tags = note.event?.tags() ?: emptyList()
val tags = note.event?.tags() ?: emptyArray()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction !in reactions.keys) {
@@ -399,75 +427,82 @@ open class Note(val idHex: String) {
}
}
@Synchronized
fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
if (briefInfo !in relays) {
relays = relays + briefInfo
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
relays = relays + relay.url
if (relay.brief !in relays) {
addRelaySync(relay.brief)
liveSet?.innerRelays?.invalidateData()
}
}
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any {
it.key.author?.pubkeyHex == user.pubkeyHex || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
} || zapPayments.any {
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
val response = if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent)
} else {
null
private fun recursiveIsPaidByCalculation(
account: Account,
remainingZapPayments: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit
) {
if (remainingZapPayments.isEmpty()) {
return
}
val next = remainingZapPayments.first()
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
if (response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent.requestAuthor())) {
onWasZappedByAuthor()
} else {
recursiveIsPaidByCalculation(
account,
remainingZapPayments.minus(next),
onWasZappedByAuthor
)
}
}
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
}
}
fun publicZapAuthors(): Set<User> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author
}.toSet()
private fun recursiveIsZappedByCalculation(
option: Int?,
user: User,
account: Account,
remainingZapEvents: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit
) {
if (remainingZapEvents.isEmpty()) {
return
}
val next = remainingZapEvents.first()
if (next.first.author?.pubkeyHex == user.pubkeyHex) {
onWasZappedByAuthor()
} else {
account.decryptZapContentAuthor(next.first) {
if (it.pubKey == user.pubkeyHex && (option == null || option == (it as? LnZapEvent)?.zappedPollOption())) {
onWasZappedByAuthor()
} else {
recursiveIsZappedByCalculation(option, user, account, remainingZapEvents.minus(next), onWasZappedByAuthor)
}
}
}
}
fun publicZapAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author?.pubkeyHex
}.toSet()
fun isZappedBy(user: User, account: Account, onWasZappedByAuthor: () -> Unit) {
recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
}
}
fun reactionAuthors(): Set<User> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author }
}.flatten().toSet()
}
fun reactionAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author?.pubkeyHex }
}.flatten().toSet()
}
fun replyAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return replies.mapNotNull {
it.author?.pubkeyHex
}.toSet()
}
fun replyAuthors(): Set<User> {
// Zaps who the requester was the user
return replies.mapNotNull {
it.author
}.toSet()
}
fun boostAuthors(): Set<User> {
// Zaps who the requester was the user
return boosts.mapNotNull {
it.author
}.toSet()
fun isZappedBy(option: Int?, user: User, account: Account, onWasZappedByAuthor: () -> Unit) {
recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
}
fun getReactionBy(user: User): String? {
@@ -502,44 +537,81 @@ open class Note(val idHex: String) {
}.flatten()
}
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.filter { it.amount != null }
.associate {
it.lnInvoice() to it.amount
}
.toMap()
private fun updateZapTotal() {
var sumOfAmounts = BigDecimal.ZERO
val completedPayments = if (privKey != null && walletServicePubkey != null) {
// Payments confirmed by the User's Wallet
zapPayments
.asSequence()
.filter {
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
response is PayInvoiceSuccessResponse
}
.associate {
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
// Regular Zap Receipts
zaps.values.forEach {
val noteEvent = it?.event
if (noteEvent is LnZapEvent) {
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
}
}
zapsAmount = sumOfAmounts
}
private fun recursiveZappedAmountCalculation(
invoiceSet: LinkedHashSet<String>,
remainingZapPayments: List<Pair<Note, Note?>>,
signer: NostrSigner,
output: BigDecimal,
onReady: (BigDecimal) -> Unit
) {
if (remainingZapPayments.isEmpty()) {
onReady(output)
return
}
val next = remainingZapPayments.first()
(next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent ->
if (noteEvent is PayInvoiceSuccessResponse) {
(next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice ->
val amount = try {
if (lnInvoice == null) {
null
} else {
LnInvoiceUtil.getAmountInSats(lnInvoice)
}
LnInvoiceUtil.getAmountInSats(invoice)
} catch (e: java.lang.Exception) {
null
}
lnInvoice to amount
var newAmount = output
if (amount != null && !invoiceSet.contains(invoice)) {
invoiceSet.add(invoice)
newAmount += amount
}
recursiveZappedAmountCalculation(
invoiceSet,
remainingZapPayments.minus(next),
signer,
newAmount,
onReady
)
}
.toMap()
} else {
emptyMap()
}
}
}
fun zappedAmountWithNWCPayments(signer: NostrSigner, onReady: (BigDecimal) -> Unit) {
if (zapPayments.isEmpty()) {
onReady(zapsAmount)
}
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
zaps.forEach {
(it.value?.event as? LnZapEvent)?.lnInvoice()?.let {
invoiceSet.add(it)
}
}
recursiveZappedAmountCalculation(
invoiceSet,
zapPayments.toList(),
signer,
zapsAmount,
onReady
)
}
fun hasPledgeBy(user: User): Boolean {
@@ -650,42 +722,106 @@ open class Note(val idHex: String) {
boosts = emptyList()
reports = emptyMap()
zaps = emptyMap()
zapsAmount = BigDecimal.ZERO
}
fun clearEOSE() {
lastReactionsDownloadTime = emptyMap()
}
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
val thisEvent = event ?: return false
val isBoostedNoteHidden = if (thisEvent is GenericRepostEvent || thisEvent is RepostEvent || thisEvent is CommunityPostApprovalEvent) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
} else {
false
}
val isHiddenByWord = if (thisEvent is BaseTextNoteEvent) {
accountChoices.hiddenWords.any {
thisEvent.content.contains(it, true)
}
} else {
false
}
val isSensitive = thisEvent.isSensitive()
return isBoostedNoteHidden || isHiddenByWord ||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
accountChoices.spammers.contains(author?.pubkeyHex) ||
(isSensitive && accountChoices.showSensitiveContent == false)
}
var liveSet: NoteLiveSet? = null
var flowSet: NoteFlowSet? = null
@Synchronized
fun createOrDestroyLiveSync(create: Boolean) {
if (create) {
if (liveSet == null) {
liveSet = NoteLiveSet(this)
}
} else {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
fun live(): NoteLiveSet {
if (liveSet == null) {
liveSet = NoteLiveSet(this)
createOrDestroyLiveSync(true)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
createOrDestroyLiveSync(false)
}
}
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
if (event == null) return false
val isBoostedNoteHidden = if (event is GenericRepostEvent || event is RepostEvent || event is CommunityPostApprovalEvent) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = NoteFlowSet(this)
}
} else {
false
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet?.destroy()
flowSet = null
}
}
}
val isSensitive = event?.isSensitive() ?: false
return isBoostedNoteHidden ||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
accountChoices.spammers.contains(author?.pubkeyHex) ||
(isSensitive && accountChoices.showSensitiveContent == false)
fun flow(): NoteFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
@Stable
class NoteFlowSet(u: Note) {
// Observers line up here.
val metadata = NoteBundledRefresherFlow(u)
fun isInUse(): Boolean {
return metadata.stateFlow.subscriptionCount.value > 0
}
fun destroy() {
metadata.destroy()
}
}
@@ -710,7 +846,7 @@ class NoteLiveSet(u: Note) {
val authorChanges = innerMetadata.map {
it.note.author
}
}.distinctUntilChanged()
val hasEvent = innerMetadata.map {
it.note.event != null
@@ -734,14 +870,12 @@ class NoteLiveSet(u: Note) {
it.note.boosts.size
}.distinctUntilChanged()
val boostList = innerBoosts.map {
it.note.boosts.toImmutableList()
}.distinctUntilChanged()
val relayInfo = innerRelays.map {
it.note.relays.map {
RelayBriefInfo(it)
}.toImmutableList()
it.note.relays
}
val content = innerMetadata.map {
it.note.event?.content() ?: ""
}
fun isInUse(): Boolean {
@@ -757,8 +891,7 @@ class NoteLiveSet(u: Note) {
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers() ||
boostList.hasObservers()
boostCount.hasObservers()
}
fun destroy() {
@@ -772,6 +905,28 @@ class NoteLiveSet(u: Note) {
}
}
@Stable
class NoteBundledRefresherFlow(val note: Note) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(NoteState(note))
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
stateFlow.emit(NoteState(note))
}
}
}
@Stable
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -800,6 +955,7 @@ class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteSta
}
}
@Stable
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
@@ -823,9 +979,22 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
@Immutable
class NoteState(val note: Note)
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
)
object RelayBriefInfoCache {
val cache = LruCache<String, RelayBriefInfo?>(50)
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
)
fun get(url: String): RelayBriefInfo {
val info = cache[url]
if (info != null) return info
val newInfo = RelayBriefInfo(url)
cache.put(url, newInfo)
return newInfo
}
}

View File

@@ -15,5 +15,5 @@ data class RelaySetupInfo(
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false
) {
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}

View File

@@ -1,16 +0,0 @@
package com.vitorpamplona.amethyst.model
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}

View File

@@ -4,14 +4,36 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
@Stable
class Settings(
var preferredLanguage: String? = null,
var automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS
data class Settings(
val theme: ThemeType = ThemeType.SYSTEM,
val preferredLanguage: String? = null,
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
val dontShowPushNotificationSelector: Boolean = false,
val dontAskForNotificationPermissions: Boolean = false
)
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
SYSTEM(0, R.string.system),
LIGHT(1, R.string.light),
DARK(2, R.string.dark)
}
fun parseThemeType(code: Int?): ThemeType {
return when (code) {
ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM
ThemeType.LIGHT.screenCode -> ThemeType.LIGHT
ThemeType.DARK.screenCode -> ThemeType.DARK
else -> {
ThemeType.SYSTEM
}
}
}
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always),
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
NEVER(false, 2, R.string.connectivity_type_never)
@@ -38,3 +60,28 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
}
}
}
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never)
}
fun parseBooleanType(code: Boolean?): BooleanType {
return when (code) {
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
BooleanType.NEVER.prefCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}
fun parseBooleanType(screenCode: Int): BooleanType {
return when (screenCode) {
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
BooleanType.NEVER.screenCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}

View File

@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@Stable
@@ -123,6 +124,7 @@ class User(val pubkeyHex: String) {
}
liveSet?.innerRelays?.invalidateData()
flowSet?.relays?.invalidateData()
}
fun addReport(note: Note) {
@@ -356,20 +358,75 @@ class User(val pubkeyHex: String) {
}
var liveSet: UserLiveSet? = null
var flowSet: UserFlowSet? = null
fun live(): UserLiveSet {
if (liveSet == null) {
liveSet = UserLiveSet(this)
createOrDestroyLiveSync(true)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
createOrDestroyLiveSync(false)
}
}
@Synchronized
fun createOrDestroyLiveSync(create: Boolean) {
if (create) {
if (liveSet == null) {
liveSet = UserLiveSet(this)
}
} else {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = UserFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet?.destroy()
flowSet = null
}
}
}
fun flow(): UserFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
@Stable
class UserFlowSet(u: User) {
// Observers line up here.
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean {
return relays.stateFlow.subscriptionCount.value > 0
}
fun destroy() {
relays.destroy()
}
}
@Stable
@@ -396,7 +453,9 @@ class UserLiveSet(u: User) {
val relays = innerRelays.map { it }
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val bookmarks = innerBookmarks.map {
it
}
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map {
@@ -476,6 +535,27 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
}
}
@Stable
class UserBundledRefresherFlow(val user: User) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(UserState(user))
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
stateFlow.emit(UserState(user))
}
}
}
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()

View File

@@ -11,7 +11,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@@ -62,6 +62,31 @@ val noProtocolUrlValidator = try {
val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() {
fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlImage(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"]
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlVideo(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"]
)
} else {
null
}
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>
@@ -86,14 +111,7 @@ class RichTextParser() {
}
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl)
} else {
null
}
parseMediaUrl(fullUrl)
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
@@ -290,3 +308,9 @@ class SchemelessUrlSegment(segment: String, val url: String, val extras: String?
@Immutable
class RegularTextSegment(segment: String) : Segment(segment)
fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}

View File

@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.events.Event
@@ -16,8 +18,6 @@ data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonNode
)
@@ -36,34 +36,104 @@ class CashuProcessor {
for (proof in proofs) {
totalAmount += proof.get("amount").asLong()
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
} catch (e: Exception) {
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
}
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
checkNotInMainThread()
runCatching {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError)
milliSats = token.totalAmount * 1000, // Make invoice and leave room for fees
message = "Calculate Fees for Cashu",
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
onSuccess = { fees ->
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = (token.totalAmount - fees) * 1000, // Make invoice and leave room for fees
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, fees, onSuccess, onError, context)
},
onProgress = {
},
onError = onError,
context = context
)
},
onError = onError,
context
)
},
onProgress = {
},
onError = onError
onError = onError,
context = context
)
}
}
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fun feeCalculator(
mintAddress: String,
invoice: String,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context
) {
checkNotInMainThread()
try {
val client = HttpClient.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost != null) {
onSuccess(
feeCost
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
onError(
context.getString(R.string.cashu_failed_redemption),
if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
}
)
}
}
} catch (e: Exception) {
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
}
}
private fun meltInvoice(token: CashuToken, invoice: String, fees: Int, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
try {
val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
@@ -88,13 +158,24 @@ class CashuProcessor {
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
onSuccess(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_successful_redemption_explainer, token.totalAmount.toString(), fees.toString())
)
} else {
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
onError(
context.getString(R.string.cashu_failed_redemption),
if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
}
)
}
}
} catch (e: Exception) {
onError("Token melt failure: " + e.message)
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
}
}
}

View File

@@ -1,311 +0,0 @@
package com.vitorpamplona.amethyst.service
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.util.LruCache
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
enum class SignerType {
SIGN_EVENT,
NIP04_ENCRYPT,
NIP04_DECRYPT,
NIP44_ENCRYPT,
NIP44_DECRYPT,
GET_PUBLIC_KEY,
DECRYPT_ZAP_EVENT
}
object ExternalSignerUtils {
val content = LruCache<String, String>(10)
var isActivityRunning: Boolean = false
val cachedDecryptedContent = mutableMapOf<HexKey, String>()
lateinit var account: Account
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var decryptResultLauncher: ActivityResultLauncher<Intent>
private lateinit var blockListResultLauncher: ActivityResultLauncher<Intent>
@OptIn(DelicateCoroutinesApi::class)
fun requestRejectedToast() {
GlobalScope.launch(Dispatchers.Main) {
Toast.makeText(
Amethyst.instance,
Amethyst.instance.getString(R.string.sign_request_rejected),
Toast.LENGTH_SHORT
).show()
}
}
@OptIn(DelicateCoroutinesApi::class)
fun default() {
isActivityRunning = false
ServiceManager.shouldPauseService = true
GlobalScope.launch(Dispatchers.IO) {
isActivityRunning = false
ServiceManager.shouldPauseService = true
}
}
@OptIn(DelicateCoroutinesApi::class)
fun start(activity: MainActivity) {
activityResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val event = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
content.put(id, event)
}
}
default()
}
decryptResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val event = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
content.put(id, event)
cachedDecryptedContent[id] = event
}
}
default()
}
blockListResultLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_OK) {
requestRejectedToast()
} else {
val decryptedContent = it.data?.getStringExtra("signature") ?: ""
val id = it.data?.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
cachedDecryptedContent[id] = decryptedContent
account.live.invalidateData()
}
}
default()
}
}
@OptIn(DelicateCoroutinesApi::class)
fun openSigner(
data: String,
type: SignerType,
intentResult: ActivityResultLauncher<Intent>,
pubKey: HexKey,
id: String
) {
try {
ServiceManager.shouldPauseService = false
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data"))
val signerType = when (type) {
SignerType.SIGN_EVENT -> "sign_event"
SignerType.NIP04_ENCRYPT -> "nip04_encrypt"
SignerType.NIP04_DECRYPT -> "nip04_decrypt"
SignerType.NIP44_ENCRYPT -> "nip44_encrypt"
SignerType.NIP44_DECRYPT -> "nip44_decrypt"
SignerType.GET_PUBLIC_KEY -> "get_public_key"
SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event"
}
intent.putExtra("type", signerType)
intent.putExtra("pubKey", pubKey)
intent.putExtra("id", id)
if (type !== SignerType.GET_PUBLIC_KEY) {
intent.putExtra("current_user", account.keyPair.pubKey.toNpub())
}
intent.`package` = "com.greenart7c3.nostrsigner"
intentResult.launch(intent)
} catch (e: Exception) {
Log.e("Signer", "Error opening Signer app", e)
GlobalScope.launch(Dispatchers.Main) {
Toast.makeText(
Amethyst.instance,
Amethyst.instance.getString(R.string.error_opening_external_signer),
Toast.LENGTH_SHORT
).show()
}
}
}
fun openSigner(event: EventInterface, columnName: String = "signature") {
checkNotInMainThread()
val result = getDataFromResolver(SignerType.SIGN_EVENT, arrayOf(event.toJson(), event.pubKey()), columnName)
if (result !== null) {
content.put(event.id(), result)
return
}
ServiceManager.shouldPauseService = false
isActivityRunning = true
openSigner(
event.toJson(),
SignerType.SIGN_EVENT,
activityResultLauncher,
"",
event.id()
)
while (isActivityRunning) {
Thread.sleep(100)
}
}
fun decryptBlockList(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
cachedDecryptedContent[id] = result
return
}
isActivityRunning = true
openSigner(
encryptedContent,
signerType,
blockListResultLauncher,
pubKey,
id
)
}
fun getDataFromResolver(signerType: SignerType, data: Array<out String>, columnName: String = "signature"): String? {
val localData = if (signerType !== SignerType.GET_PUBLIC_KEY) {
data.toList().plus(account.keyPair.pubKey.toNpub()).toTypedArray()
} else {
data
}
Amethyst.instance.contentResolver.query(
Uri.parse("content://com.greenart7c3.nostrsigner.$signerType"),
localData,
null,
null,
null
).use {
if (it !== null) {
if (it.moveToFirst()) {
val index = it.getColumnIndex(columnName)
if (index < 0) {
Log.d("getDataFromResolver", "column '$columnName' not found")
return null
}
return it.getString(index)
}
}
}
return null
}
fun decrypt(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
cachedDecryptedContent[id] = result
return
}
isActivityRunning = true
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
while (isActivityRunning) {
// do nothing
}
}
fun decryptDM(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
cachedDecryptedContent[id] = result
return
}
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
}
fun decryptBookmark(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
cachedDecryptedContent[id] = result
return
}
openSigner(
encryptedContent,
signerType,
decryptResultLauncher,
pubKey,
id
)
}
fun encrypt(decryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_ENCRYPT) {
val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey))
if (result !== null) {
content.put(id, result)
return
}
isActivityRunning = true
openSigner(
decryptedContent,
signerType,
activityResultLauncher,
pubKey,
id
)
while (isActivityRunning) {
Thread.sleep(100)
}
}
fun decryptZapEvent(event: LnZapRequestEvent) {
val result = getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey))
if (result !== null) {
content.put(event.id, result)
cachedDecryptedContent[event.id] = result
return
}
openSigner(
event.toJson(),
SignerType.DECRYPT_ZAP_EVENT,
decryptResultLauncher,
event.pubKey,
event.id
)
}
}

View File

@@ -2,30 +2,39 @@ package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.media.MediaDataSource
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.BitmapParams
import android.os.Build
import android.util.Log
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import io.trbl.blurhash.BlurHash
import java.io.IOException
import kotlin.math.roundToInt
class FileHeader(
val url: String,
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?,
val alt: String? = null,
val sensitiveContent: Boolean = false
val blurHash: String?
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(
fileUrl: String,
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError)
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
} else {
onError()
}
@@ -37,10 +46,8 @@ class FileHeader(
fun prepare(
data: ByteArray,
fileUrl: String,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
@@ -75,11 +82,45 @@ class FileHeader(
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
val intArray = IntArray(thumbnail.width * thumbnail.height)
thumbnail.getPixels(
intArray,
0,
thumbnail.width,
0,
0,
thumbnail.width,
thumbnail.height
)
if (aspectRatio > 1) {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 9, (9 * (1 / aspectRatio)).roundToInt())
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, (9 * aspectRatio).roundToInt(), 9)
} else {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
}
}
if (newDim != "0x0") {
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
}
} else {
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent))
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
@@ -87,3 +128,79 @@ class FileHeader(
}
}
}
fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
val raw: ByteArray? = getEmbeddedPicture()
if (raw != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw))
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val params = BitmapParams()
params.preferredConfig = Bitmap.Config.ARGB_8888
// Fall back to middle of video
// Note: METADATA_KEY_DURATION unit is in ms, not us.
val thumbnailTimeUs: Long = (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params)
} else {
null
}
} else {
null
}
}
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
val width = prepareVideoWidth() ?: return null
val height = prepareVideoHeight() ?: return null
return "${width}x$height"
}
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
return if (widthData.isNullOrEmpty()) {
null
} else {
widthData.toInt()
}
}
fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
return if (heightData.isNullOrEmpty()) {
null
} else {
heightData.toInt()
}
}
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
override fun getSize(): Long {
return imageData.size.toLong()
}
@Throws(IOException::class)
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
if (position >= imageData.size) {
return -1
}
val newSize = if (position + size > imageData.size) {
size - ((position.toInt() + size) - imageData.size)
} else {
size
}
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)
return newSize
}
@Throws(IOException::class)
override fun close() {}
}

View File

@@ -1,14 +1,25 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
import kotlin.properties.Delegates
object HttpClient {
val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L)
val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L)
var proxyChangeListeners = ArrayList<() -> Unit>()
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
var defaultHttpClient: OkHttpClient? = null
// fires off every time value of the property changes
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
@@ -19,21 +30,61 @@ object HttpClient {
}
}
fun start(account: Account?) {
this.internalProxy = account?.proxy
fun start(proxy: Proxy?) {
if (internalProxy != proxy) {
this.internalProxy = proxy
this.defaultHttpClient = getHttpClient()
}
}
fun getHttpClient(): OkHttpClient {
val seconds = if (internalProxy != null) 20L else 10L
fun changeTimeouts(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout")
if (this.defaultTimeout.seconds != timeout.seconds) {
this.defaultTimeout = timeout
this.defaultHttpClient = getHttpClient()
}
}
fun getHttpClient(timeout: Duration): OkHttpClient {
val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(internalProxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.addInterceptor(DefaultContentTypeInterceptor())
.followRedirects(true)
.followSslRedirects(true)
.build()
}
class DefaultContentTypeInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request = originalRequest
.newBuilder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.build()
return chain.proceed(requestWithUserAgent)
}
}
fun getHttpClientForRelays(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout)
}
return defaultHttpClient!!
}
fun getHttpClient(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout)
}
return defaultHttpClient!!
}
fun getProxy(): Proxy? {
return internalProxy
}

View File

@@ -9,7 +9,7 @@ import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier() {
class Nip05NostrAddressVerifier() {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")

View File

@@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
import java.util.regex.Pattern
@Immutable
class NIP30Parser {
class Nip30CustomEmoji {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {

View File

@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.service
import java.net.URI
import java.net.URLDecoder
class Nip44UrlParser {
fun parse(url: String): Map<String, String> {
return try {
fragments(URI(url))
} catch (e: Exception) {
emptyMap()
}
}
private fun fragments(uri: URI): Map<String, String> {
if (uri.rawFragment == null) return emptyMap()
return uri.rawFragment.split('&').associate { keyValuePair ->
val parts = keyValuePair.split('=')
val name = parts.firstOrNull() ?: ""
val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
Pair(name, value)
}
}
}

View File

@@ -6,7 +6,7 @@ import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
// Rename to the corect nip number when ready.
object Nip47 {
object Nip47WalletConnectParser {
fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D

View File

@@ -0,0 +1,92 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import okhttp3.Request
object Nip96MediaServers {
val DEFAULT =
listOf(
ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("Nostrage", "https://nostrage.com"),
ServerName("Sove", "https://sove.rent"),
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(url: String): Nip96Retriever.ServerInfo {
val cached = cache[url]
if (cached != null) return cached
val fetched = Nip96Retriever().loadInfo(url)
cache[url] = fetched
return fetched
}
}
class Nip96Retriever {
data class ServerInfo(
@JsonProperty("api_url") val apiUrl: String,
@JsonProperty("download_url") val downloadUrl: String? = null,
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
@JsonProperty("tos_url") val tosUrl: String? = null,
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf()
)
data class Plan(
@JsonProperty("name") val name: String? = null,
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
@JsonProperty("url") val url: String? = null,
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
@JsonProperty("media_transformations") val mediaTransformations: Map<MimeType, Array<String>> = emptyMap()
)
fun parse(body: String): ServerInfo {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, ServerInfo::class.java)
}
suspend fun loadInfo(
baseUrl: String
): ServerInfo {
checkNotInMainThread()
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
HttpClient.getHttpClient()
.newCall(request)
.execute().use { response ->
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
return parse(body)
} else {
throw RuntimeException("Resulting Message from $baseUrl is an error: ${response.code} ${response.message}")
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e
}
}
}
}
}
typealias PlanName = String
typealias MimeType = String

View File

@@ -0,0 +1,279 @@
package com.vitorpamplona.amethyst.service
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
class Nip96Uploader(val account: Account?) {
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96MediaServers.ServerName,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
val serverInfo = Nip96Retriever().loadInfo(
server.baseUrl
)
return uploadImage(uri, contentType, size, alt, sensitiveContent, serverInfo, contentResolver, onProgress)
}
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
return uploadImage(imageInputStream, length, myContentType, alt, sensitiveContent, server, onProgress)
}
suspend fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
checkNotInMainThread()
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("expiration", "")
.addFormDataPart("size", length.toString())
.also { body ->
alt?.let { body.addFormDataPart("alt", it) }
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
contentType?.let { body.addFormDataPart("content_type", it) }
}
.addFormDataPart(
"file",
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
)
.build()
nip98Header(server.apiUrl)?.let {
requestBuilder.addHeader("Authorization", it)
}
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl)
.post(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)
if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, onProgress)
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
} else {
throw RuntimeException("Failed to upload with message: ${result.message}")
}
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
}
}
suspend fun delete(
hash: String,
contentType: String?,
server: Nip96Retriever.ServerInfo
): Boolean {
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBuilder = Request.Builder()
nip98Header(server.apiUrl)?.let {
requestBuilder.addHeader("Authorization", it)
}
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
val request = requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseDeleteResults(str)
return result.status == "success"
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
}
}
private suspend fun waitProcessing(
result: Nip96Result,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
val client = HttpClient.getHttpClient()
var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
onProgress((currentResult.percentage ?: 100) / 100f)
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(result.processingUrl)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
it.body.use {
currentResult = parseResults(it.string())
}
}
}
delay(500)
}
onProgress((currentResult.percentage ?: 100) / 100f)
val nip94 = currentResult.nip94Event
if (nip94 != null) {
return nip94
} else {
throw RuntimeException("Error waiting for processing. Final result is unavailable")
}
}
suspend fun nip98Header(url: String): String? {
return withTimeoutOrNull(5000) {
suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken ->
continuation.resume(authorizationToken)
}
}
}
}
fun nip98Header(url: String, method: String, file: ByteArray? = null, onReady: (String?) -> Unit) {
val myAccount = account
if (myAccount == null) {
onReady(null)
return
}
myAccount.createHTTPAuthorization(url, method, file) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
onReady("Nostr $encodedNIP98Event")
}
}
data class DeleteResult(
val status: String?,
val message: String?
)
private fun parseDeleteResults(body: String): DeleteResult {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, DeleteResult::class.java)
}
data class Nip96Result(
val status: String? = null,
val message: String? = null,
@JsonProperty("processing_url")
val processingUrl: String? = null,
val percentage: Int? = null,
@JsonProperty("nip94_event")
val nip94Event: PartialEvent? = null
)
class PartialEvent(
val tags: Array<Array<String>>? = null,
val content: String? = null
)
private fun parseResults(body: String): Nip96Result {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, Nip96Result::class.java)
}
}

View File

@@ -2,12 +2,15 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
@@ -16,11 +19,13 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
@@ -29,11 +34,15 @@ import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
// TODO: Migrate this to a property of AccountVi
object NostrAccountDataSource : NostrDataSource("AccountData") {
lateinit var account: Account
var otherAccounts = listOf<HexKey>()
val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
@@ -68,6 +77,18 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createOtherAccountsBaseFilter(): TypedFilter? {
if (otherAccounts.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind, ContactListEvent.kind, AdvertisedRelayListEvent.kind, MuteListEvent.kind, PeopleListEvent.kind),
authors = otherAccounts.filter { it != account.userProfile().pubkeyHex },
limit = 100
)
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
@@ -83,7 +104,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, MuteListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100
)
@@ -96,7 +117,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList.value)?.relayList
)
)
}
@@ -111,26 +132,32 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createNotificationFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,
ChannelMessageEvent.kind,
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 4000,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
fun createNotificationFilter(): TypedFilter {
val since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList.value)?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,
ChannelMessageEvent.kind,
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 4000,
since = since
)
)
)
}
fun createGiftWrapsToMeFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
@@ -141,89 +168,111 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
val accountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
if (hasLoadedTheBasics[account.userProfile()] != null) {
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList.value, relayUrl, time)
} else {
hasLoadedTheBasics[account.userProfile()] = true
invalidateFilters()
}
}
override fun consume(event: Event, relay: Relay) {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGift(privateKey)?.let {
this.consume(it, relay)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGift(account.keyPair.pubKey, cached)?.let {
this.consume(it, relay)
}
// Avoid decrypting over and over again if the event already exist.
if (LocalCache.getNoteIfExists(event.id) != null) return
event.cachedGift(account.signer) {
this.consume(it, relay)
}
}
if (event is SealedGossipEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGossip(privateKey)?.let {
LocalCache.justConsume(it, relay)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
LocalCache.justConsume(it, relay)
}
}
// Avoid decrypting over and over again if the event already exist.
if (LocalCache.getNoteIfExists(event.id) != null) return
// Don't store sealed gossips to avoid rebroadcasting by mistake.
event.cachedGossip(account.signer) {
LocalCache.justConsume(it, relay)
}
} else {
LocalCache.justConsume(event, relay)
}
}
}
override fun markAsSeenOnRelay(eventId: String, relay: Relay) {
checkNotInMainThread()
super.markAsSeenOnRelay(eventId, relay)
val note = LocalCache.getNoteIfExists(eventId) ?: return
val privKey = account.keyPair.privKey ?: return
val noteEvent = note.event ?: return
markInnerAsSeenOnRelay(noteEvent, privKey, relay)
}
private fun markInnerAsSeenOnRelay(noteEvent: EventInterface, privKey: ByteArray, relay: Relay) {
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
if (noteEvent is GiftWrapEvent) {
noteEvent.cachedGift(account.signer) { gift ->
markInnerAsSeenOnRelay(gift, privKey, relay)
}
} else if (noteEvent is SealedGossipEvent) {
noteEvent.cachedGossip(account.signer) { rumor ->
markInnerAsSeenOnRelay(rumor, privKey, relay)
}
}
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter()
).ifEmpty { null }
return if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everthing about the user logged in
accountChannel.typedFilters = listOfNotNull(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter()
).ifEmpty { null }
} else {
// just the basics.
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountBookmarkListFilter()
).ifEmpty { null }
}
}
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
if (this::account.isInitialized) {
val event = account.createAuthEvent(relay, challenge)
if (event != null) {
account.createAuthEvent(relay, challenge) {
Client.send(
event,
it,
relay.url
)
}
}
}
override fun notify(relay: Relay, description: String) {
super.notify(relay, description)
if (this::account.isInitialized) {
account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description))
}
}
}

View File

@@ -56,6 +56,10 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
}
}
fun clearEOSEs(account: Account) {
latestEOSEs.removeDataFor(account.userProfile())
}
val inandoutChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
}

View File

@@ -44,9 +44,11 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
)
)
fun createMyChannelsFilter(): TypedFilter {
fun createMyChannelsFilter(): TypedFilter? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
@@ -60,6 +62,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
fun createLastChannelInfoFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
@@ -75,6 +79,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
@@ -93,7 +99,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
}
override fun updateChannelFilters() {
val list = listOf(
val list = listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter()

View File

@@ -10,12 +10,12 @@ import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.Error
abstract class NostrDataSource(val debugName: String) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -26,6 +26,8 @@ abstract class NostrDataSource(val debugName: String) {
private var eventCounter = mapOf<String, Counter>()
var changingFilters = AtomicBoolean()
private var active: Boolean = false
fun printCounter() {
eventCounter.forEach {
Log.d("STATE DUMP", "Received Events ${it.key}: ${it.value.counter}")
@@ -33,7 +35,7 @@ abstract class NostrDataSource(val debugName: String) {
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
if (subscriptionId in subscriptions.keys) {
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
@@ -44,6 +46,9 @@ abstract class NostrDataSource(val debugName: String) {
}
consume(event, relay)
if (afterEOSE) {
markAsEOSE(subscriptionId, relay)
}
}
}
@@ -51,7 +56,7 @@ abstract class NostrDataSource(val debugName: String) {
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay, subscriptionId: String?) {
override fun onRelayStateChange(type: Relay.StateType, relay: Relay, subscriptionId: String?) {
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
// Relay.Type.CONNECT -> "connected."
// Relay.Type.DISCONNECT -> "disconnected."
@@ -59,12 +64,8 @@ abstract class NostrDataSource(val debugName: String) {
// Relay.Type.EOSE -> "sent all events it had stored."
// }}")
if (type == Relay.Type.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
// updates a per subscripton since date
subscriptions[subscriptionId]?.updateEOSE(
TimeUtils.fiveMinutesAgo(), // in case people's clock is slighly off.
relay.url
)
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
markAsEOSE(subscriptionId, relay)
}
}
@@ -77,6 +78,13 @@ abstract class NostrDataSource(val debugName: String) {
override fun onAuth(relay: Relay, challenge: String) {
auth(relay, challenge)
}
override fun onNotify(
relay: Relay,
description: String
) {
notify(relay, description)
}
}
init {
@@ -85,6 +93,7 @@ abstract class NostrDataSource(val debugName: String) {
}
fun destroy() {
// makes sure to run
stop()
Client.unsubscribe(clientListener)
scope.cancel()
@@ -93,14 +102,29 @@ abstract class NostrDataSource(val debugName: String) {
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
active = true
resetFilters()
}
open fun stop() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { channel ->
Client.close(channel.id)
channel.typedFilters = null
GlobalScope.launch(Dispatchers.IO) {
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
}
open fun stopSync() {
active = false
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { subscription ->
Client.close(subscription.id)
subscription.typedFilters = null
}
}
@@ -137,6 +161,7 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFiltersSuspend() {
println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active")
checkNotInMainThread()
// saves the channels that are currently active
@@ -150,29 +175,35 @@ abstract class NostrDataSource(val debugName: String) {
// Makes sure to only send an updated filter when it actually changes.
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
if (updatedSubscription.id in currentFilters.keys) {
if (updatedSubscriotionNewFilters == null) {
if (updatedSubscriptionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
// hasn't changed, does nothing.
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
if (active) {
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriptionNewFilters)
}
}
}
} else {
if (updatedSubscriotionNewFilters == null) {
if (updatedSubscriptionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
if (active) {
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
}
}
}
@@ -189,6 +220,14 @@ abstract class NostrDataSource(val debugName: String) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
}
open fun markAsEOSE(subscriptionId: String, relay: Relay) {
subscriptions[subscriptionId]?.updateEOSE(
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
relay.url
)
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
open fun notify(relay: Relay, description: String) = Unit
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -8,18 +9,90 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount()
var job: Job? = null
override fun start() {
job?.cancel()
job = scope.launch(Dispatchers.IO) {
account.liveDiscoveryFollowLists.collect {
if (this@NostrDiscoveryDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
super.start()
}
override fun stop() {
super.stop()
job?.cancel()
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
),
hashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(ClassifiedsEvent.kind),
tags = mapOf(
"t" to it.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
},
geohashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(ClassifiedsEvent.kind),
tags = mapOf(
"g" to it.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
)
}
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
return listOfNotNull(
TypedFilter(
@@ -28,7 +101,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
),
follows?.let {
@@ -38,7 +111,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.kind),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
@@ -46,33 +119,37 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val followChats = account.selectedChatsFollowList().toList()
return listOf(
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = follows,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
),
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
if (followChats.isNotEmpty()) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
)
} else {
null
}
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
@@ -80,13 +157,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
authors = follows,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
@@ -100,13 +177,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
fun createLiveStreamGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
@@ -120,13 +197,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
@@ -140,13 +217,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
fun createPublicChatsGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
@@ -160,13 +237,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
@@ -180,13 +257,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
fun createCommunitiesGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
if (hashToLoad.isNullOrEmpty()) return null
@@ -200,26 +277,29 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time)
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList.value, relayUrl, time)
}
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter()
)
).ifEmpty { null }
discoveryFeedChannel.typedFilters = createLiveStreamFilter()
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsTagsFilter(),
createPublicChatsGeohashesFilter()
)
).ifEmpty { null }
}
}

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -19,42 +19,42 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount()
private val cacheListener: (UserState) -> Unit = {
invalidateFilters()
}
var job: Job? = null
@OptIn(DelicateCoroutinesApi::class)
override fun start() {
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.observeForever(cacheListener)
job?.cancel()
job = scope.launch(Dispatchers.IO) {
// creates cache on main
withContext(Dispatchers.Main) {
account.userProfile().live()
}
account.liveHomeFollowLists.collect {
if (this@NostrHomeDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
super.start()
}
@OptIn(DelicateCoroutinesApi::class)
override fun stop() {
super.stop()
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.removeObserver(cacheListener)
}
}
job?.cancel()
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return TypedFilter(
@@ -76,13 +76,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
if (hashToLoad.isEmpty()) return null
@@ -96,13 +96,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}.flatten()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList
)
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet()
val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null
if (hashToLoad.isEmpty()) return null
@@ -116,13 +116,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}.flatten()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList
)
)
}
fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
if (communitiesToLoad.isEmpty()) return null
@@ -143,13 +143,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
"a" to communitiesToLoad.toList()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList
)
)
}
val followAccountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList.value, relayUrl, time)
}
override fun updateChannelFilters() {

View File

@@ -7,12 +7,13 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.signers.NostrSigner
class NostrLnZapPaymentResponseDataSource(
private val fromServiceHex: String,
private val toUserHex: String,
private val replyingToHex: String,
private val authSigningKey: ByteArray
private val authSigner: NostrSigner
) : NostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
@@ -44,10 +45,11 @@ class NostrLnZapPaymentResponseDataSource(
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
val event = RelayAuthEvent.create(relay.url, challenge, "", authSigningKey)
Client.send(
event,
relay.url
)
RelayAuthEvent.create(relay.url, challenge, authSigner) {
Client.send(
it,
relay.url
)
}
}
}

View File

@@ -5,9 +5,27 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
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.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.NNSEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
private var searchString: String? = null
@@ -19,7 +37,13 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
}
val hexToWatch = try {
Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey()
val isAStraightHex = if (HexValidator.isHex(mySearchString)) {
Hex.decode(mySearchString).toHexKey()
} else {
null
}
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
} catch (e: Exception) {
null
}
@@ -91,11 +115,18 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
}
fun search(searchString: String) {
this.searchString = searchString
invalidateFilters()
if (this.searchString != searchString) {
println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
this.searchString = searchString
invalidateFilters()
}
}
fun clear() {
searchString = null
if (searchString != null) {
println("DataSource: ${this.javaClass.simpleName} Clear")
searchString = null
invalidateFilters()
}
}
}

View File

@@ -2,45 +2,52 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
private var addressesToWatch = setOf<Note>()
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch.filter { it.address() != null }
if (addressesToWatch.isEmpty()) {
return null
}
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
PollNoteEvent.kind,
CommunityPostApprovalEvent.kind,
LiveActivitiesChatMessageEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime,
limit = 1000 // Max amount of "replies" to download on a specific event.
)
return groupByEOSEPresence(eventsToWatch).mapNotNull {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
PollNoteEvent.kind,
CommunityPostApprovalEvent.kind,
LiveActivitiesChatMessageEvent.kind
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
}
)
}
}
@@ -59,7 +66,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 1000 // Max amount of "replies" to download on a specific event.
limit = 5
)
)
} else {
@@ -69,7 +76,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
limit = 1000 // Max amount of "replies" to download on a specific event.
limit = 5
)
)
}
@@ -78,13 +85,11 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
val reactionsToWatch = eventsToWatch
if (reactionsToWatch.isEmpty()) {
if (eventsToWatch.isEmpty()) {
return null
}
return reactionsToWatch.map {
return groupByEOSEPresence(eventsToWatch).map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
@@ -97,8 +102,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LnZapEvent.kind,
PollNoteEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime,
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
@@ -148,6 +153,15 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
}
addressesToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
@@ -190,3 +204,45 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
}
}
fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> {
return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values
}
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> {
return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values
}
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
notes.forEach {
it.lastReactionsDownloadTime.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}
fun findMinimumEOSEsForUsers(users: List<User>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
users.forEach {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
return minLatestEOSEs
}

View File

@@ -17,99 +17,71 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
if (firstTimers.isEmpty()) return null
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
authors = firstTimers,
limit = 10 * firstTimers.size
kinds = listOf(MetadataEvent.kind),
authors = firstTimers
)
)
)
}
fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null }
if (secondTimers.isEmpty()) return null
return groupByEOSEPresence(secondTimers).map { group ->
val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
authors = groupIds,
since = minEOSEs
)
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to groupIds),
since = minEOSEs
)
)
)
)
}
fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
)
}
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
since = minLatestEOSEs
)
)
}.flatten()
}
val userChannel = requestNewChannel() { time, relayUrl ->
usersToWatch.forEach {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
checkNotInMainThread()
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun updateChannelFilters() {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
val neverGottenAnEOSE = mutableSetOf<String>()
usersToWatch.forEach {
if (it.latestEOSEs.isEmpty()) { // first time
neverGottenAnEOSE.add(it.pubkeyHex)
} else {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
if (it.info?.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
}
override fun updateChannelFilters() {
checkNotInMainThread()
userChannel.typedFilters = listOfNotNull(
createUserMetadataFilter(minLatestEOSEs),
createUserStatusFilter(minLatestEOSEs),
createUserReportFilter(minLatestEOSEs)
).ifEmpty { null }
userChannelFirstTimers.typedFilters = listOfNotNull(
createUserMetadataFilter()
createUserMetadataFilter(),
createUserMetadataStatusReportFilter()
).flatten().ifEmpty { null }
}

View File

@@ -17,10 +17,12 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
.toSet()
.ifEmpty { null } ?: return null
if (eventsToLoad.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
ids = eventsToLoad.map { it.substring(0, 8) }
ids = eventsToLoad.toList()
)
)
}

View File

@@ -4,7 +4,23 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.events.AppRecommendationEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
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.LnZapEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -7,14 +8,37 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account
val scope = Amethyst.instance.applicationIOScope
val latestEOSEs = EOSEAccount()
fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
var job: Job? = null
override fun start() {
job?.cancel()
job = scope.launch(Dispatchers.IO) {
account.liveStoriesFollowLists.collect {
if (this@NostrVideoDataSource::account.isInitialized) {
invalidateFilters()
}
}
}
super.start()
}
override fun stop() {
super.stop()
job?.cancel()
}
fun createContextualFilter(): TypedFilter {
val follows = account.liveStoriesFollowLists.value?.users?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
@@ -22,15 +46,15 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
authors = follows,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null
if (hashToLoad.isNullOrEmpty()) return null
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
@@ -42,13 +66,13 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
}.flatten()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList
)
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultStoriesFollowList)
val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null
if (hashToLoad.isNullOrEmpty()) return null
@@ -62,13 +86,13 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
}.flatten()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList
)
)
}
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time)
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList.value, relayUrl, time)
}
override fun updateChannelFilters() {

View File

@@ -13,6 +13,14 @@ object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
val fiveMinutes = 1000 * 60 * 5
fun isOnlineCached(url: String?): Boolean {
if (url.isNullOrBlank()) return false
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
return checkOnlineCache.get(url).online
}
return false
}
fun isOnline(url: String?): Boolean {
checkNotInMainThread()
@@ -21,6 +29,8 @@ object OnlineChecker {
return checkOnlineCache.get(url).online
}
Log.d("OnlineChecker", "isOnline $url")
return try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
@@ -29,6 +39,7 @@ object OnlineChecker {
.build()
val result = HttpClient.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))

View File

@@ -14,7 +14,6 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.round
@@ -35,7 +34,7 @@ class ZapPaymentHandler(val account: Account) {
pollOption: Int?,
message: String,
context: Context,
onError: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType
@@ -48,7 +47,10 @@ class ZapPaymentHandler(val account: Account) {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
onError(
context.getString(R.string.missing_lud16),
context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
)
return@withContext
}
@@ -121,6 +123,9 @@ class ZapPaymentHandler(val account: Account) {
)
} else {
onError(
context.getString(
R.string.missing_lud16
),
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex
@@ -136,12 +141,38 @@ class ZapPaymentHandler(val account: Account) {
} else {
launch(Dispatchers.IO) {
// Awaits for the event to come back to LocalCache.
delay(5000)
onProgress(1f)
var count = 0
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
count++
Thread.sleep(5000)
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
} else {
onProgress(1f)
}
}
}
}
private fun prepareZapRequestIfNeeded(
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
onReady: (String?) -> Unit
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest ->
onReady(zapRequest.toJson())
}
} else {
onReady(null)
}
}
private suspend fun innerZap(
lud16: String,
note: Note,
@@ -149,59 +180,55 @@ class ZapPaymentHandler(val account: Account) {
pollOption: Int?,
message: String,
context: Context,
onError: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null
) {
var zapRequestJson = ""
onProgress(0.05f)
if (zapType != LnZapEvent.ZapType.NONZAP) {
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser)
if (zapRequest != null) {
zapRequestJson = zapRequest.toJson()
}
}
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
onProgress(0.10f)
onProgress(0.10f)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
response.error?.message
?: response.error?.code?.toString()
?: "Error parsing error message"
)
} else {
onProgress(1f)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
context.getString(R.string.error_dialog_pay_invoice_error),
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString()
?: "Error parsing error message"
)
)
} else {
onProgress(1f)
}
}
}
)
onProgress(0.8f)
} else {
try {
)
onProgress(0.8f)
} else {
onPayInvoiceThroughIntent(it)
} catch (e: Exception) {
onError(context.getString(R.string.lightning_wallets_not_found2))
onProgress(0f)
}
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress
)
},
onError = onError,
onProgress = onProgress,
context = context
)
}
}
}

View File

@@ -1,15 +0,0 @@
package com.vitorpamplona.amethyst.service.connectivitystatus
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
object ConnectivityStatus {
private val onMobileData = mutableStateOf(false)
val isOnMobileData: MutableState<Boolean> = onMobileData
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
if (onMobileData.value != isOnMobileData) {
onMobileData.value = isOnMobileData
}
}
}

View File

@@ -1,14 +1,14 @@
package com.vitorpamplona.amethyst.service.lnurl
import android.content.Context
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.math.BigDecimal
import java.math.RoundingMode
@@ -31,14 +31,25 @@ class LightningAddressResolver() {
return null
}
private suspend fun fetchLightningAddressJson(lnaddress: String, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
private fun fetchLightningAddressJson(
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context
) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
return@withContext
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
lnaddress
)
)
return
}
try {
@@ -51,16 +62,41 @@ class LightningAddressResolver() {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("The receiver's lightning service at $url is not available. It was calculated from the lightning address \"${lnaddress}\". Error: ${it.code}. Check if the server is up and if the lightning address is correct")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnaddress,
it.code.toString()
)
)
}
}
} catch (e: Exception) {
e.printStackTrace()
onError("Could not resolve $url. Check if the server is up and if the lightning address $lnaddress is correct")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnaddress
)
)
}
}
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
fun fetchLightningInvoice(
lnCallback: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context
) {
checkNotInMainThread()
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
@@ -80,29 +116,34 @@ class LightningAddressResolver() {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.could_not_fetch_invoice_from, lnCallback)
)
}
}
}
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, context: Context) {
fetchLightningAddressJson(
lnaddress,
onSuccess = {
onSuccess(it.toByteArray().toLnUrl())
},
onError = onError
onError = onError,
context = context
)
}
suspend fun lnAddressInvoice(
fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context
) {
val mapper = jacksonObjectMapper()
@@ -114,14 +155,20 @@ class LightningAddressResolver() {
val lnurlp = try {
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup)
)
null
}
val callback = lnurlp?.get("callback")?.asText()
if (callback == null) {
onError("Callback URL not found in the User's lightning address server configuration")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration)
)
}
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
@@ -138,7 +185,10 @@ class LightningAddressResolver() {
val lnInvoice = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup)
)
null
}
@@ -151,21 +201,40 @@ class LightningAddressResolver() {
onSuccess(pr)
} else {
onProgress(0.0f)
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from $lnaddress. It should have been $expectedAmountInSats")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnaddress,
expectedAmountInSats.toString()
)
)
}
} ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
onProgress(0.0f)
onError("Unable to create a lightning invoice before sending the zap. The receiver's lightning wallet sent the following error: $reason")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
reason
)
)
} ?: run {
onProgress(0.0f)
onError("nable to create a lightning invoice before sending the zap. Element pr not found in the resulting JSON.")
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json)
)
}
},
onError = onError
onError = onError,
context
)
}
},
onError = onError
onError = onError,
context
)
}
}

View File

@@ -7,8 +7,6 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.SignerType
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
@@ -33,120 +31,49 @@ class EventNotificationConsumer(private val applicationContext: Context) {
// PushNotification Wraps don't include a receiver.
// Test with all logged in accounts
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && (acc.keyPair.privKey != null || acc.loginWithExternalSigner)) {
consumeIfMatchesAccount(event, acc)
if (it.hasPrivKey || it.loggedInWithExternalSigner) {
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
consumeIfMatchesAccount(event, acc)
}
}
}
}
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
val key = account.keyPair.privKey
if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
pushWrappedEvent.content,
pushWrappedEvent.pubKey,
pushWrappedEvent.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id] ?: ""
}
pushWrappedEvent.unwrap(cached)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
LocalCache.justConsume(notificationEvent, null)
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
} else if (key != null) {
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
unwrapAndConsume(notificationEvent, account) { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
}
}
private fun unwrapAndConsume(event: Event, account: Account): Event? {
if (!LocalCache.justVerify(event)) return null
private fun unwrapAndConsume(event: Event, account: Account, onReady: (Event) -> Unit) {
if (!LocalCache.justVerify(event)) return
return when (event) {
when (event) {
is GiftWrapEvent -> {
val key = account.keyPair.privKey
if (key != null) {
event.cachedGift(key)?.let {
unwrapAndConsume(it, account)
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGift(account.keyPair.pubKey, cached)?.let {
unwrapAndConsume(it, account)
}
} else {
null
event.cachedGift(account.signer) {
unwrapAndConsume(it, account, onReady)
}
}
is SealedGossipEvent -> {
val key = account.keyPair.privKey
if (key != null) {
event.cachedGossip(key)?.let {
// this is not verifiable
LocalCache.justConsume(it, null)
it
}
} else if (account.loginWithExternalSigner) {
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
if (cached == null) {
ExternalSignerUtils.decrypt(
event.content,
event.pubKey,
event.id,
SignerType.NIP44_DECRYPT
)
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
}
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
LocalCache.justConsume(it, null)
it
}
} else {
null
event.cachedGossip(account.signer) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
}
else -> {
LocalCache.justConsume(event, null)
event
onReady(event)
}
}
}
@@ -189,7 +116,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
@@ -201,11 +128,12 @@ class EventNotificationConsumer(private val applicationContext: Context) {
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
acc.decryptContent(note) { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
@@ -218,39 +146,35 @@ class EventNotificationConsumer(private val applicationContext: Context) {
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
Pair(author, decryptedContent.content)
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
Pair(noteZapRequest.author, noteZapRequest.event?.content())
} else {
Pair(noteZapRequest.author, null)
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
acc.decryptZapContentAuthor(noteZapRequest) {
val author = LocalCache.getOrCreateUser(it.pubKey)
val senderInfo = Pair(author, it.content.ifBlank { null })
acc.decryptContent(noteZapped) {
val zappedContent = it.split("\n").get(0)
val user = senderInfo.first.toBestDisplayName()
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
}
}
}
val zappedContent =
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
val user = senderInfo?.first?.toBestDisplayName() ?: ""
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo?.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
}
}

View File

@@ -4,6 +4,7 @@ import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.Dispatchers
@@ -15,18 +16,39 @@ import okhttp3.RequestBody.Companion.toRequestBody
class RegisterAccounts(
private val accounts: List<AccountInfo>
) {
private fun recursiveAuthCreation(
notificationToken: String,
remainingTos: List<Pair<Account, String>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit
) {
if (remainingTos.isEmpty()) {
onReady(output)
return
}
val next = remainingTos.first()
next.first.createAuthEvent(next.second, notificationToken) {
output.add(it)
recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady)
}
}
// creates proof that it controls all accounts
private fun signEventsToProveControlOfAccounts(
private suspend fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String
): List<RelayAuthEvent> {
return accounts.mapNotNull {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
notificationToken: String,
onReady: (List<RelayAuthEvent>) -> Unit
) {
val readyToSend = accounts.mapNotNull {
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
val readRelays = acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
if (relayToUse != null) {
acc.createAuthEvent(relayToUse, notificationToken)
Pair(acc, relayToUse)
} else {
null
}
@@ -34,6 +56,14 @@ class RegisterAccounts(
null
}
}
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
recursiveAuthCreation(
notificationToken,
readyToSend,
listOfAuthEvents,
onReady
)
}
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
@@ -58,13 +88,20 @@ class RegisterAccounts(
it.isSuccessful
}
} catch (e: java.lang.Exception) {
Log.e("FirebaseMsgService", "Unable to register with push server", e)
val tag = if (BuildConfig.FLAVOR == "play") {
"FirebaseMsgService"
} else {
"UnifiedPushService"
}
Log.e(tag, "Unable to register with push server", e)
}
}
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
signEventsToProveControlOfAccounts(accounts, notificationToken) {
postRegistrationEvent(it)
}
PushNotificationUtils.hasInit = true
}
}

View File

@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst
package com.vitorpamplona.amethyst.service.playback
import android.app.PendingIntent
import android.content.Context
@@ -84,6 +84,7 @@ class MultiPlayerPlaybackManager(
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cachedPositions.add(uri, player.currentPosition)
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
@@ -92,7 +93,10 @@ class MultiPlayerPlaybackManager(
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
STATE_IDLE -> {
cachedPositions.add(uri, player.currentPosition)
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
@@ -102,7 +106,10 @@ class MultiPlayerPlaybackManager(
}
}
else -> {
cachedPositions.add(uri, player.currentPosition)
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
}
}

View File

@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst
package com.vitorpamplona.amethyst.service.playback
import android.content.ComponentName
import android.content.Context

View File

@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst
package com.vitorpamplona.amethyst.service.playback
import android.content.Intent
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -8,6 +10,7 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HttpClient
@UnstableApi // Extend MediaSessionService
@@ -23,7 +26,9 @@ class PlaybackService : MediaSessionService() {
}
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient()))
return ProgressiveMediaSource.Factory(
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient())
)
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
@@ -55,6 +60,8 @@ class PlaybackService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
Log.d("Lifetime Event", "PlaybackService.onCreate")
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
@@ -70,7 +77,15 @@ class PlaybackService : MediaSessionService() {
toDestroyProgressive?.releaseAppPlayers()
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d("Lifetime Event", "onTaskRemoved")
}
override fun onDestroy() {
Log.d("Lifetime Event", "PlaybackService.onDestroy")
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers()

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst
package com.vitorpamplona.amethyst.service.playback
import android.annotation.SuppressLint
import android.content.Context
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
@@ -10,9 +10,10 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import okhttp3.OkHttpClient
import java.io.File
@UnstableApi object VideoCache {
@SuppressLint("UnsafeOptInUsageError")
class VideoCache {
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
@@ -42,11 +43,7 @@ import java.io.File
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
fun get(context: Context, client: OkHttpClient): CacheDataSource.Factory {
if (!this::simpleCache.isInitialized) {
initFileCache(context)
}
fun get(client: OkHttpClient): CacheDataSource.Factory {
// Renews the factory because OkHttpMight have changed.
renewCacheFactory(client)

View File

@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst
package com.vitorpamplona.amethyst.service.playback
import android.util.LruCache

View File

@@ -1,8 +1,9 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import kotlinx.coroutines.*
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Locale
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")

View File

@@ -6,25 +6,15 @@ import kotlinx.coroutines.withContext
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.lang.IllegalArgumentException
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
/* for <meta property="og:" to get title */
private val META_OG_TITLE = arrayOf("og:title", "\"og:title\"", "'og:title'")
/* for <meta property="og:" to get description */
private val META_OG_DESCRIPTION =
arrayOf("og:description", "\"og:description\"", "'og:description'")
/* for <meta property="og:" to get image */
private val META_OG_IMAGE = arrayOf("og:image", "\"og:image\"", "'og:image'")
/*for <meta name=... to get title */
private val META_NAME_TITLE = arrayOf(
/*for <meta itemprop=... to get title */
private val META_X_TITLE = arrayOf(
"og:title", "\"og:title\"", "'og:title'", "name", "\"name\"", "'name'",
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
@@ -33,8 +23,10 @@ private val META_NAME_TITLE = arrayOf(
"'title'"
)
/*for <meta name=... to get description */
private val META_NAME_DESCRIPTION = arrayOf(
/*for <meta itemprop=... to get description */
private val META_X_DESCRIPTION = arrayOf(
"og:description", "\"og:description\"", "'og:description'",
"description", "\"description\"", "'description'",
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
@@ -43,22 +35,15 @@ private val META_NAME_DESCRIPTION = arrayOf(
"'description'"
)
/*for <meta name=... to get image */
private val META_NAME_IMAGE = arrayOf(
/*for <meta itemprop=... to get image */
private val META_X_IMAGE = arrayOf(
"og:image", "\"og:image\"", "'og:image'",
"image", "\"image\"", "'image'",
"twitter:image",
"\"twitter:image\"",
"'twitter:image'"
)
/*for <meta itemprop=... to get title */
private val META_ITEMPROP_TITLE = arrayOf("name", "\"name\"", "'name'")
/*for <meta itemprop=... to get description */
private val META_ITEMPROP_DESCRIPTION = arrayOf("description", "\"description\"", "'description'")
/*for <meta itemprop=... to get image */
private val META_ITEMPROP_IMAGE = arrayOf("image", "\"image\"", "'image'")
private const val CONTENT = "content"
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
@@ -84,31 +69,38 @@ suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
var image: String = ""
metaTags.forEach {
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
when (propertyTag) {
in META_OG_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
in META_OG_DESCRIPTION -> if (description.isEmpty()) {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE -> if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
in META_X_IMAGE -> if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_NAME_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
in META_NAME_DESCRIPTION -> if (description.isEmpty()) {
in META_X_TITLE -> if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
in META_X_IMAGE -> if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_ITEMPROP_TITLE -> if (title.isEmpty()) {
in META_X_TITLE -> if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_ITEMPROP_DESCRIPTION -> if (description.isEmpty()) {
in META_X_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_ITEMPROP_IMAGE -> if (image.isEmpty()) {
in META_X_IMAGE -> if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}

View File

@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
@@ -18,21 +18,47 @@ import java.util.UUID
*/
object Client : RelayPool.Listener {
private var listeners = setOf<Listener>()
private var relays = Constants.convertDefaultRelays()
private var relays = emptyArray<Relay>()
private var subscriptions = mapOf<String, List<TypedFilter>>()
@Synchronized
fun connect(relays: Array<Relay>) {
fun reconnect(relays: Array<Relay>?, onlyIfChanged: Boolean = false) {
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
checkNotInMainThread()
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
this.relays = relays
if (onlyIfChanged) {
if (!isSameRelaySetConfig(relays)) {
if (this.relays.isNotEmpty()) {
RelayPool.disconnect()
RelayPool.unregister(this)
RelayPool.unloadRelays()
}
if (relays != null) {
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.requestAndWatch()
this.relays = relays
}
}
} else {
if (this.relays.isNotEmpty()) {
RelayPool.disconnect()
RelayPool.unregister(this)
RelayPool.unloadRelays()
}
if (relays != null) {
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.requestAndWatch()
this.relays = relays
}
}
}
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>): Boolean {
if (relays.size != newRelayConfig.size) return false
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
if (relays.size != newRelayConfig?.size) return false
relays.forEach { oldRelayInfo ->
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
@@ -99,7 +125,7 @@ object Client : RelayPool.Listener {
@OptIn(DelicateCoroutinesApi::class)
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
val relay = Relay(url, true, true, feedTypes ?: emptySet())
RelayPool.addRelay(relay)
relay.connectAndRun {
@@ -126,18 +152,12 @@ object Client : RelayPool.Listener {
subscriptions = subscriptions.minus(subscriptionId)
}
fun disconnect() {
RelayPool.unregister(this)
RelayPool.disconnect()
RelayPool.unloadRelays()
}
@OptIn(DelicateCoroutinesApi::class)
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
}
}
@@ -151,7 +171,7 @@ object Client : RelayPool.Listener {
}
@OptIn(DelicateCoroutinesApi::class)
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
override fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
@@ -177,6 +197,14 @@ object Client : RelayPool.Listener {
}
}
override fun onNotify(relay: Relay, description: String) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onNotify(relay, description) }
}
}
fun subscribe(listener: Listener) {
listeners = listeners.plus(listener)
}
@@ -197,7 +225,7 @@ object Client : RelayPool.Listener {
/**
* A new message was received
*/
open fun onEvent(event: Event, subscriptionId: String, relay: Relay) = Unit
open fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) = Unit
/**
* A new or repeat message was received
@@ -207,7 +235,7 @@ object Client : RelayPool.Listener {
/**
* Connected to or disconnected from a relay
*/
open fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) = Unit
open fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?) = Unit
/**
* When an relay saves or rejects a new event.
@@ -215,5 +243,7 @@ object Client : RelayPool.Listener {
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
open fun onAuth(relay: Relay, challenge: String) = Unit
open fun onNotify(relay: Relay, description: String) = Unit
}
}

View File

@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.HttpClient
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -11,7 +10,7 @@ object Constants {
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
@@ -51,13 +50,13 @@ object Constants {
// Supporting NIP-50
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelayForSearch = arrayOf(
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }

View File

@@ -43,4 +43,8 @@ class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
followList.addOrUpdate(listCode, relayUrl, time)
}
}
fun removeDataFor(user: User) {
users = users.minus(user)
}
}

View File

@@ -2,19 +2,19 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.TimeUtils
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.net.Proxy
import java.time.Duration
import java.util.concurrent.atomic.AtomicBoolean
enum class FeedType {
@@ -24,25 +24,19 @@ enum class FeedType {
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
class Relay(
var url: String,
var read: Boolean = true,
var write: Boolean = true,
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
proxy: Proxy?
val url: String,
val read: Boolean = true,
val write: Boolean = true,
val activeTypes: Set<FeedType> = FeedType.values().toSet()
) {
val brief = RelayBriefInfoCache.get(url)
companion object {
// waits 3 minutes to reconnect once things fail
const val RECONNECTING_IN_SECONDS = 60 * 3
}
private val httpClient = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.connectTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.writeTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.followRedirects(true)
.followSslRedirects(true)
.build()
private val httpClient = HttpClient.getHttpClientForRelays()
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
@@ -58,7 +52,9 @@ class Relay(
var closingTimeInSeconds = 0L
var afterEOSE = false
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
val authResponse = mutableMapOf<HexKey, Boolean>()
fun register(listener: Listener) {
listeners = listeners.plus(listener)
@@ -77,15 +73,14 @@ class Relay(
checkNotInMainThread()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
renewFilters()
}
}
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Relay", "Relay.connect $url")
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
@@ -118,6 +113,7 @@ class Relay(
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
checkNotInMainThread()
Log.d("Relay", "Connect onOpen $url $socket")
markConnectionAsReady(
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
@@ -127,7 +123,7 @@ class Relay(
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
onConnected(this@Relay)
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -148,10 +144,12 @@ class Relay(
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
Log.w("Relay", "Relay onClosing $url: $reason")
listeners.forEach {
it.onRelayStateChange(
this@Relay,
Type.DISCONNECTING,
StateType.DISCONNECTING,
null
)
}
@@ -162,7 +160,9 @@ class Relay(
markConnectionAsClosed()
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
Log.w("Relay", "Relay onClosed $url: $reason")
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
@@ -170,7 +170,7 @@ class Relay(
errorCounter++
socket?.close(1000, "Normal close")
socket?.cancel() // 1000, "Normal close"
// Failures disconnect the relay.
markConnectionAsClosed()
@@ -183,7 +183,7 @@ class Relay(
}
fun markConnectionAsReady(pingInMs: Long, usingCompression: Boolean) {
this.afterEOSE = false
this.afterEOSEPerSubscription.clear()
this.isReady = true
this.pingInMs = pingInMs
this.usingCompression = usingCompression
@@ -193,63 +193,84 @@ class Relay(
this.socket = null
this.isReady = false
this.usingCompression = false
this.afterEOSE = false
this.afterEOSEPerSubscription.clear()
this.closingTimeInSeconds = TimeUtils.now()
}
fun processNewRelayMessage(newMessage: String) {
val msgArray = Event.mapper.readTree(newMessage)
val type = msgArray.get(0).asText()
val channel = msgArray.get(1).asText()
when (type) {
when (val type = msgArray.get(0).asText()) {
"EVENT" -> {
val subscriptionId = msgArray.get(1).asText()
val event = Event.fromJson(msgArray.get(2))
// Log.w("Relay", "Relay onEVENT $url, $channel")
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
listeners.forEach {
it.onEvent(this@Relay, channel, event)
if (afterEOSE) {
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
it.onEvent(this@Relay, subscriptionId, event, afterEOSEPerSubscription[subscriptionId] == true)
}
}
"EOSE" -> listeners.forEach {
afterEOSE = true
// Log.w("Relay", "Relay onEOSE $url, $channel")
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
val subscriptionId = msgArray.get(1).asText()
afterEOSEPerSubscription[subscriptionId] = true
// Log.w("Relay", "Relay onEOSE $url $subscriptionId")
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
}
"NOTICE" -> listeners.forEach {
Log.w("Relay", "Relay onNotice $url, $channel")
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
val message = msgArray.get(1).asText()
Log.w("Relay", "Relay onNotice $url, $message")
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
}
"OK" -> listeners.forEach {
Log.w("Relay", "Relay on OK $url, ${msgArray[1].asText()}, ${msgArray[2].asBoolean()}, ${msgArray[3].asText()}")
it.onSendResponse(this@Relay, msgArray[1].asText(), msgArray[2].asBoolean(), msgArray[3].asText())
val eventId = msgArray[1].asText()
val success = msgArray[2].asBoolean()
val message = if (msgArray.size() > 2) msgArray[3].asText() else ""
if (authResponse.containsKey(eventId)) {
val wasAlreadyAuthenticated = authResponse.get(eventId)
authResponse.put(eventId, success)
if (wasAlreadyAuthenticated != true && success) {
renewFilters()
}
}
Log.w("Relay", "Relay on OK $url, $eventId, $success, $message")
it.onSendResponse(this@Relay, eventId, success, message)
}
"AUTH" -> listeners.forEach {
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
// Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}")
it.onAuth(this@Relay, msgArray[1].asText())
}
"NOTIFY" -> listeners.forEach {
// Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}")
it.onNotify(this@Relay, msgArray[1].asText())
}
"CLOSED" -> listeners.forEach {
Log.w("Relay", "Relay onClosed $url, $newMessage")
}
else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel")
Log.w("Relay", "Unsupported message: $newMessage")
it.onError(
this@Relay,
channel,
Error("Unknown type $type on channel $channel. Msg was $newMessage")
"",
Error("Unknown type $type on channel. Msg was $newMessage")
)
}
}
}
fun disconnect() {
// httpClient.dispatcher.executorService.shutdown()
Log.d("Relay", "Relay.disconnect $url")
checkNotInMainThread()
closingTimeInSeconds = TimeUtils.now()
socket?.close(1000, "Normal close")
socket?.cancel()
socket = null
isReady = false
usingCompression = false
afterEOSE = false
afterEOSEPerSubscription.clear()
}
fun sendFilter(requestId: String) {
@@ -262,10 +283,12 @@ class Relay(
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
// println("FILTERSSENT $url $request")
// Log.d("Relay", "onFilterSent $url $requestId $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
afterEOSE = false
afterEOSEPerSubscription.clear()
}
}
} else {
@@ -290,37 +313,41 @@ class Relay(
}
}
fun renewFilters() {
// Force update all filters after AUTH.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
}
fun send(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
} else {
if (write) {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (isConnected()) {
if (isReady) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
}
}
@@ -329,7 +356,11 @@ class Relay(
}
fun close(subscriptionId: String) {
socket?.send("""["CLOSE","$subscriptionId"]""")
checkNotInMainThread()
val msg = """["CLOSE","$subscriptionId"]"""
Log.d("Relay", "Close Subscription $url $msg")
socket?.send(msg)
}
fun isSameRelayConfig(other: Relay): Boolean {
@@ -339,7 +370,7 @@ class Relay(
activeTypes == other.activeTypes
}
enum class Type {
enum class StateType {
// Websocket connected
CONNECT,
@@ -357,7 +388,7 @@ class Relay(
/**
* A new message was received
*/
fun onEvent(relay: Relay, subscriptionId: String, event: Event)
fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean)
fun onError(relay: Relay, subscriptionId: String, error: Error)
@@ -370,6 +401,11 @@ class Relay(
*
* @param type is 0 for disconnect and 1 for connect
*/
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
fun onRelayStateChange(relay: Relay, type: StateType, channel: String?)
/**
* Relay sent an invoice
*/
fun onNotify(relay: Relay, description: String)
}
}

View File

@@ -103,19 +103,21 @@ object RelayPool : Relay.Listener {
}
interface Listener {
fun onEvent(event: Event, subscriptionId: String, relay: Relay)
fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean)
fun onError(error: Error, subscriptionId: String, relay: Relay)
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
fun onAuth(relay: Relay, challenge: String)
fun onNotify(relay: Relay, description: String)
}
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
override fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean) {
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
}
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
@@ -123,9 +125,9 @@ object RelayPool : Relay.Listener {
updateStatus()
}
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
override fun onRelayStateChange(relay: Relay, type: Relay.StateType, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
if (type != Relay.Type.EOSE) {
if (type != Relay.StateType.EOSE) {
updateStatus()
}
}
@@ -138,6 +140,10 @@ object RelayPool : Relay.Listener {
listeners.forEach { it.onAuth(relay, challenge) }
}
override fun onNotify(relay: Relay, description: String) {
listeners.forEach { it.onNotify(relay, description) }
}
private fun updateStatus() {
val connected = connectedRelays()
val available = availableRelays()

View File

@@ -49,28 +49,30 @@ class TextToSpeechEngine private constructor() {
fun initTTS(context: Context, message: String) {
tts = TextToSpeech(context) {
if (it == TextToSpeech.SUCCESS) {
tts!!.language = defLanguage
tts!!.setPitch(defaultPitch)
tts!!.setSpeechRate(defaultSpeed)
tts!!.setListener(
onStart = {
onStartListener?.invoke()
},
onError = { e ->
e?.let { error ->
onErrorListener?.invoke(error)
tts?.let {
it.language = defLanguage
it.setPitch(defaultPitch)
it.setSpeechRate(defaultSpeed)
it.setListener(
onStart = {
onStartListener?.invoke()
},
onError = { e ->
e?.let { error ->
onErrorListener?.invoke(error)
}
},
onRange = { start, end ->
if (this@TextToSpeechEngine.message != null) {
onHighlightListener?.invoke(start, end)
}
},
onDone = {
onStartListener?.invoke()
}
},
onRange = { start, end ->
if (this@TextToSpeechEngine.message != null) {
onHighlightListener?.invoke(start, end)
}
},
onDone = {
onStartListener?.invoke()
}
)
speak(message)
)
speak(message)
}
} else {
onErrorListener?.invoke(getErrorText(it))
}
@@ -78,7 +80,7 @@ class TextToSpeechEngine private constructor() {
}
private fun speak(message: String): TextToSpeechEngine {
tts!!.speak(
tts?.speak(
message,
TextToSpeech.QUEUE_FLUSH,
null,

View File

@@ -21,7 +21,7 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
private var message: String? = null
private var tts: TextToSpeechEngine? = null
private var ttsEngine: TextToSpeechEngine? = null
private var onStart: (() -> Unit)? = null
@@ -44,19 +44,19 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
}
private fun initTTS() = context.get()?.run {
tts = TextToSpeechEngine.getInstance()
ttsEngine = TextToSpeechEngine.getInstance()
.setOnCompletionListener { onDoneListener?.invoke() }
.setOnErrorListener { onErrorListener?.invoke(it) }
.setOnStartListener { onStart?.invoke() }
}
fun speak(message: String): TextToSpeechHelper {
if (tts == null) {
if (ttsEngine == null) {
initTTS()
}
this.message = message
tts?.initTTS(
ttsEngine?.initTTS(
appContext,
message
)
@@ -70,8 +70,8 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
*/
fun highlight(): TextToSpeechHelper {
if (message == null) throw Exception("Message can't be null for highlighting !! Call speak() first")
tts?.setHighlightedMessage(message!!)
tts?.setOnHighlightListener { i, i2 ->
ttsEngine?.setHighlightedMessage(message!!)
ttsEngine?.setOnHighlightListener { i, i2 ->
onHighlightListener?.invoke(Pair(i, i2))
}
return this
@@ -86,8 +86,8 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
fun destroy(
action: (() -> Unit) = {}
) {
tts?.destroy()
tts = null
ttsEngine?.destroy()
ttsEngine = null
action.invoke()
INSTANCE = null
}
@@ -118,7 +118,7 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
}
fun setLanguage(locale: Locale): TextToSpeechHelper {
tts?.setLanguage(locale)
ttsEngine?.setLanguage(locale)
return this
}
@@ -126,12 +126,12 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
pitch: Float = DEF_SPEECH_AND_PITCH,
speed: Float = DEF_SPEECH_AND_PITCH
): TextToSpeechHelper {
tts?.setPitchAndSpeed(pitch, speed)
ttsEngine?.setPitchAndSpeed(pitch, speed)
return this
}
fun resetPitchAndSpeed(): TextToSpeechHelper {
tts?.resetPitchAndSpeed()
ttsEngine?.resetPitchAndSpeed()
return this
}

View File

@@ -6,7 +6,6 @@ import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle
import android.util.Log
@@ -14,26 +13,29 @@ import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.note.Nip47WalletConnectParser
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.ChannelCreateEvent
@@ -48,86 +50,146 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.Timer
import kotlin.concurrent.schedule
class MainActivity : AppCompatActivity() {
private val isOnMobileDataState = mutableStateOf(false)
private val isOnWifiDataState = mutableStateOf(false)
// Service Manager is only active when the activity is active.
val serviceManager = ServiceManager()
private var shouldPauseService = true
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
ExternalSignerUtils.start(this)
super.onCreate(savedInstanceState)
val language = LocalPreferences.getPreferredLanguage()
if (language.isNotBlank()) {
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
AppCompatDelegate.setApplicationLocales(appLocale)
}
Log.d("Lifetime Event", "MainActivity.onCreate")
setContent {
val themeViewModel: ThemeViewModel = viewModel()
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
themeViewModel.onChange(LocalPreferences.getTheme())
AmethystTheme(themeViewModel) {
val displayFeatures = calculateDisplayFeatures(this)
val windowSizeClass = calculateWindowSizeClass(this)
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(isOnMobileDataState) {
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
}
AmethystTheme(sharedPreferencesViewModel) {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
color = MaterialTheme.colorScheme.background
) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(this@MainActivity)
val accountStateViewModel: AccountStateViewModel = viewModel()
accountStateViewModel.serviceManager = serviceManager
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
}
AccountScreen(accountStateViewModel, themeViewModel)
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
}
}
}
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
val connectivityManager =
getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.requestNetwork(networkRequest, networkCallback)
fun prepareToLaunchSigner() {
shouldPauseService = false
}
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
Log.d("Lifetime Event", "MainActivity.onResume")
// starts muted every time
DefaultMutedSetting.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
if (ServiceManager.shouldPauseService) {
ServiceManager.start(this@MainActivity)
// Keep connection alive if it's calling the signer app
Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) {
serviceManager.justStart()
}
}
GlobalScope.launch(Dispatchers.IO) {
PushNotificationUtils.init(LocalPreferences.allSavedAccounts())
}
val connectivityManager = (getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
connectivityManager.registerDefaultNetworkCallback(networkCallback)
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
?.let { updateNetworkCapabilities(it) }
// resets state until next External Signer Call
Timer().schedule(350) {
shouldPauseService = true
}
}
override fun onPause() {
ServiceManager.cleanObservers()
Log.d("Lifetime Event", "MainActivity.onPause")
LanguageTranslatorService.clear()
serviceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
debugState(this)
GlobalScope.launch(Dispatchers.IO) {
debugState(this@MainActivity)
}
// }
if (ServiceManager.shouldPauseService) {
ServiceManager.pause()
Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) {
serviceManager.pauseForGood()
}
}
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
.unregisterNetworkCallback(networkCallback)
super.onPause()
}
override fun onStart() {
super.onStart()
Log.d("Lifetime Event", "MainActivity.onStart")
}
override fun onStop() {
super.onStop()
// Graph doesn't completely clear.
// GlobalScope.launch(Dispatchers.Default) {
// serviceManager.trimMemory()
// }
Log.d("Lifetime Event", "MainActivity.onStop")
}
override fun onDestroy() {
Log.d("Lifetime Event", "MainActivity.onDestroy")
GlobalScope.launch(Dispatchers.IO) {
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
keepPlayingMutex = null
}
super.onDestroy()
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
keepPlayingMutex = null
}
/**
@@ -139,23 +201,54 @@ class MainActivity : AppCompatActivity() {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) {
ServiceManager.trimMemory()
serviceManager.trimMemory()
}
}
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
var changedNetwork = false
if (isOnMobileDataState.value != isOnMobileData) {
isOnMobileDataState.value = isOnMobileData
changedNetwork = true
}
if (isOnWifiDataState.value != isOnWifi) {
isOnWifiDataState.value = isOnWifi
changedNetwork = true
}
if (changedNetwork) {
if (isOnMobileData) {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE)
} else {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI)
}
}
return changedNetwork
}
@OptIn(DelicateCoroutinesApi::class)
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// network is available for use
var lastNetwork: Network? = null
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("NETWORKCALLBACK", "onAvailable: Disconnecting and connecting again")
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
if (ServiceManager.shouldPauseService) {
ServiceManager.pause()
ServiceManager.start(this@MainActivity)
Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService")
if (shouldPauseService && lastNetwork != null && lastNetwork != network) {
GlobalScope.launch(Dispatchers.IO) {
serviceManager.forceRestart()
}
}
lastNetwork = network
}
// Network capabilities have changed for the network
@@ -166,26 +259,9 @@ class MainActivity : AppCompatActivity() {
super.onCapabilitiesChanged(network, networkCapabilities)
GlobalScope.launch(Dispatchers.IO) {
val hasMobileData =
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
ConnectivityStatus.updateConnectivityStatus(
hasMobileData,
hasWifi
)
}
}
// lost network connection
override fun onLost(network: Network) {
super.onLost(network)
Log.d("NETWORKCALLBACK", "onLost: Disconnecting and pausing relay's connection")
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
if (ServiceManager.shouldPauseService) {
ServiceManager.pause()
Log.d("ServiceManager NetworkCallback", "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}")
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
serviceManager.forceRestart()
}
}
}
@@ -243,7 +319,7 @@ fun uriToRoute(uri: String?): String? {
}
} ?: try {
uri?.let {
Nip47.parse(it)
Nip47WalletConnectParser.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}

View File

@@ -11,7 +11,10 @@ import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import okio.BufferedSource
import okio.IOException
import okio.buffer

View File

@@ -1,260 +0,0 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
import okio.IOException
import okio.source
import java.io.InputStream
import java.util.Base64
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
object ImageUploader {
lateinit var account: Account
fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
server: ServersAvailable,
contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
val myServer = when (server) {
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
// ImgurServer()
// }
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer()
}
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
NostrBuildServer()
}
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
NostrFilesDevServer()
}
ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> {
NostrCheckMeServer()
}
else -> {
NostrBuildServer()
}
}
uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError)
}
fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
server: FileServer,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
server.inputParameterName(contentType),
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
)
.build()
server.clientID(requestBody.toString())?.let {
requestBuilder.addHeader("Authorization", it)
}
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.postUrl(contentType))
.post(requestBody)
val request = requestBuilder.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
response.body.use { body ->
val url = server.parseUrlFromSuccess(body.string())
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
onSuccess(url, contentType)
}
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
})
}
fun NIP98Header(url: String, method: String, body: String): String {
val noteJson = account.createHTTPAuthorization(url, method, body)?.toJson() ?: ""
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
return "Nostr " + encodedNIP98Event
}
}
abstract class FileServer {
abstract fun postUrl(contentType: String?): String
abstract fun parseUrlFromSuccess(body: String): String?
abstract fun inputParameterName(contentType: String?): String
open fun clientID(info: String): String? = null
}
class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID(info: String) = null
}
class ImgurServer : FileServer() {
override fun postUrl(contentType: String?): String {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
}
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID(info: String) = "Client-ID e6aea87296f3f96"
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val data = tree?.get("data")
val data0 = data?.get(0)
val url = data0?.get("url")
return url.toString().replace("\"", "")
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
override fun clientID(info: String) = ImageUploader.NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info)
}
class NostrFilesDevServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
return tree?.get("url")?.asText()
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
override fun clientID(info: String) = null
}
class NostrCheckMeServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("url")?.asText()
var id = tree?.get("id")?.asText()
var isCompleted = false
val client = HttpClient.getHttpClient()
var requrl = "https://nostrcheck.me/api/v1/media?id=" + id // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
val request = Request.Builder()
.url(requrl)
.addHeader("Authorization", ImageUploader.NIP98Header(requrl, "GET", ""))
.get()
.build()
while (!isCompleted) {
client.newCall(request).execute().use {
val tree = jacksonObjectMapper().readTree(it.body.string())
isCompleted = tree?.get("status")?.asText() == "completed"
try {
Thread.sleep(500)
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
}
return url
}
override fun inputParameterName(contentType: String?): String {
return "mediafile"
}
override fun clientID(body: String) = ImageUploader.NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body)
}

View File

@@ -0,0 +1,54 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@Composable
fun InformationDialog(
title: String,
textContent: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
SelectionContainer {
Text(textContent)
}
},
confirmButton = {
Button(onClick = onDismiss, colors = buttonColors, contentPadding = PaddingValues(horizontal = Size16dp)) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null
)
Spacer(StdHorzSpacer)
Text(stringResource(R.string.error_dialog_button_ok))
}
}
}
)
}

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
@@ -16,19 +15,18 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -52,6 +50,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
@@ -64,11 +63,11 @@ 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.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatroomKey
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -83,7 +82,7 @@ import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel(
key = accountViewModel.account.userProfile().pubkeyHex + "SearchBarViewModel",
key = "SearchBarViewModel",
factory = SearchBarViewModel.Factory(
accountViewModel.account
)
@@ -133,7 +132,7 @@ fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () ->
Text(
text = "",
color = MaterialTheme.colors.placeholderText,
color = MaterialTheme.colorScheme.placeholderText,
fontWeight = FontWeight.Bold
)
}
@@ -194,7 +193,7 @@ private fun RenderSearch(
}
}
DisposableEffect(Unit) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Join Start")
@@ -270,7 +269,7 @@ private fun SearchEditTextForJoin(
placeholder = {
Text(
text = stringResource(R.string.channel_list_user_or_group_id_demo),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
trailingIcon = {
@@ -300,9 +299,12 @@ private fun RenderSearchResults(
nav: (String) -> Unit
) {
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsState()
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
val scope = rememberCoroutineScope()
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle()
val automaticallyShowProfilePicture = remember {
accountViewModel.settings.showProfilePictures.value
}
Row(
modifier = Modifier
@@ -312,10 +314,7 @@ private fun RenderSearchResults(
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
contentPadding = FeedPadding,
state = listState
) {
itemsIndexed(
@@ -323,10 +322,8 @@ private fun RenderSearchResults(
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(item, accountViewModel) {
scope.launch(Dispatchers.IO) {
val withKey = ChatroomKey(persistentSetOf(item.pubkeyHex))
accountViewModel.userProfile().createChatroom(withKey)
nav("Room/${withKey.hashCode()}")
accountViewModel.createChatRoomFor(item) {
nav("Room/$it")
}
searchBarViewModel.clear()
@@ -337,7 +334,7 @@ private fun RenderSearchResults(
channels,
key = { _, item -> "c" + item.idHex }
) { _, item ->
RenderChannel(item) {
RenderChannel(item, automaticallyShowProfilePicture) {
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
@@ -350,6 +347,7 @@ private fun RenderSearchResults(
@Composable
private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel,
loadProfilePicture: Boolean,
onClick: () -> Unit
) {
val hasNewMessages = remember {
@@ -368,7 +366,8 @@ private fun RenderChannel(
channelLastTime = null,
channelLastContent = item.summary(),
hasNewMessages,
onClick = onClick
onClick = onClick,
loadProfilePicture = loadProfilePicture
)
}
@@ -410,7 +409,7 @@ fun UserComposeForChat(
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
thickness = DividerThickness
)
}
}
@@ -426,7 +425,7 @@ private fun DisplayUserAboutInfo(baseUser: User) {
Text(
text = about,
color = MaterialTheme.colors.placeholderText,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)

View File

@@ -10,11 +10,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -75,7 +75,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
placeholder = {
Text(
text = stringResource(R.string.my_awesome_group),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -94,7 +94,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)
@@ -111,7 +111,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
placeholder = {
Text(
text = stringResource(R.string.about_us),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(

View File

@@ -9,23 +9,31 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
data class ServerOption(
val server: Nip96MediaServers.ServerName,
val isNip95: Boolean
)
@Stable
open class NewMediaModel : ViewModel() {
var account: Account? = null
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
val imageUploadingError = MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var selectedServer by mutableStateOf<ServerOption?>(null)
var alt by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
@@ -41,20 +49,7 @@ open class NewMediaModel : ViewModel() {
this.account = account
this.galleryUri = uri
this.mediaType = contentType
this.selectedServer = defaultServer()
// if (selectedServer == ServersAvailable.IMGUR) {
// selectedServer = ServersAvailable.IMGUR_NIP_94
// } else
if (selectedServer == ServersAvailable.NOSTRIMG) {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRCHECK_ME) {
selectedServer = ServersAvailable.NOSTRCHECK_ME_NIP_94
}
this.selectedServer = ServerOption(defaultServer(), false)
}
fun upload(context: Context, relayList: List<Relay>? = null) {
@@ -74,8 +69,7 @@ open class NewMediaModel : ViewModel() {
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (selectedServer == ServersAvailable.NIP95) {
if (serverToUse.isNip95) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
@@ -93,30 +87,35 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) {
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(
imageUrl,
mimeType,
alt,
sensitiveContent,
relayList = relayList
)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
try {
val result = Nip96Uploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse.server,
contentResolver = contentResolver,
onProgress = { percent: Float ->
uploadingPercentage.value = 0.2f + (0.2f * percent)
}
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList
)
} catch (e: Exception) {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload: ${e.message}")
}
)
}
}
}
},
@@ -140,62 +139,83 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.0f
alt = ""
selectedServer = account?.defaultFileServer
selectedServer = ServerOption(defaultServer(), false)
}
fun canPost(): Boolean {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<Relay>? = null
) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
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 }
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
return
}
if (imageData != null) {
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
FileHeader.prepare(
imageData,
imageUrl,
mimeType,
alt,
sensitiveContent,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
account?.sendHeader(it, relayList)
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
if (imageData != null) {
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
FileHeader.prepare(
data = imageData,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
account?.sendHeader(imageUrl, magnet, it, alt, sensitiveContent, originalHash, relayList) {
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
@@ -207,25 +227,21 @@ open class NewMediaModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
alt,
sensitiveContent,
null,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.40f
val nip95 = account?.createNip95(bytes, headerInfo = it)
if (nip95 != null) {
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f
account?.sendNip95(nip95.first, nip95.second, relayList)
}
account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
}
},
onError = {
uploadingDescription.value = null
@@ -242,7 +258,7 @@ open class NewMediaModel : ViewModel() {
fun isImage() = mediaType?.startsWith("image")
fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.defaultFileServer
fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded
}

View File

@@ -21,12 +21,12 @@ 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.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -46,7 +46,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@@ -81,7 +81,9 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
var showRelaysDialog by remember {
mutableStateOf(false)
}
var relayList = account.activeWriteRelays()
var relayList = remember {
accountViewModel.account.activeWriteRelays().toImmutableList()
}
Dialog(
onDismissRequest = { onClose() },
@@ -135,7 +137,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colors.onBackground
tint = MaterialTheme.colorScheme.onBackground
)
}
}
@@ -144,7 +146,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
onPost = {
onClose()
postViewModel.upload(context, relayList)
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it.server) }
},
isActive = postViewModel.canPost()
)
@@ -168,26 +170,19 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
}
}
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
// selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94 ||
selectedServer == ServersAvailable.NOSTRCHECK_ME_NIP_94
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) {
val fileServers = listOf(
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
ServerOption(
Nip96MediaServers.ServerName(
"NIP95",
stringResource(id = R.string.upload_server_relays_nip95)
),
true
)
)
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
val resolver = LocalContext.current.contentResolver
Row(
@@ -250,10 +245,12 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
placeholder = fileServers.firstOrNull {
it.server == accountViewModel.account.defaultFileServer
}?.server?.name ?: fileServers[0].server.name,
options = fileServerOptions,
onSelect = {
postViewModel.selectedServer = fileServers[it].first
postViewModel.selectedServer = fileServers[it]
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
@@ -261,44 +258,40 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
)
}
if (isNIP94Server(postViewModel.selectedServer) ||
postViewModel.selectedServer == ServersAvailable.NIP95
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
SettingSwitchItem(
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
SettingSwitchItem(
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.alt,
onValueChange = { postViewModel.alt = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.alt,
onValueChange = { postViewModel.alt = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
}
)
}
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.bechToBytes
@@ -69,19 +70,19 @@ class NewMessageTagger(
if (results?.key?.type == Nip19.Type.USER) {
val user = dao.getOrCreateUser(results.key.hex)
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
getNostrAddress(user.pubkeyNpub(), results.restOfWord)
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = dao.getOrCreateNote(results.key.hex)
"nostr:${note.toNEvent()}${results.restOfWord}"
getNostrAddress(note.toNEvent(), results.restOfWord)
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = dao.getOrCreateNote(results.key.hex)
"nostr:${note.toNEvent()}${results.restOfWord}"
getNostrAddress(note.toNEvent(), results.restOfWord)
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"nostr:${note.idNote()}${results.restOfWord}"
getNostrAddress(note.idNote(), results.restOfWord)
} else {
word
}
@@ -92,6 +93,18 @@ class NewMessageTagger(
}.joinToString("\n")
}
fun getNostrAddress(bechAddress: String, restOfTheWord: String): String {
return if (restOfTheWord.isEmpty()) {
"nostr:$bechAddress"
} else {
if (Bech32.alphabet.contains(restOfTheWord.get(0), true)) {
"nostr:$bechAddress $restOfTheWord"
} else {
"nostr:${bechAddress}$restOfTheWord"
}
}
}
@Immutable
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)

View File

@@ -5,10 +5,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -38,13 +38,13 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) {
} catch (e: Exception) { pollViewModel.isValidClosedAt.value = false }
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
val colorInValid = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.placeholderText
val colorValid = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText
)
Row(
@@ -60,13 +60,13 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_closing_time),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_closing_time_days),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)

View File

@@ -5,10 +5,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -38,13 +38,13 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
} catch (e: Exception) { pollViewModel.isValidConsensusThreshold.value = false }
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
val colorInValid = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.placeholderText
val colorValid = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText
)
Row(
@@ -60,13 +60,13 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_consensus_threshold),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_consensus_threshold_percent),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)

View File

@@ -1,10 +1,14 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -36,13 +40,13 @@ fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_option_description),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(

View File

@@ -3,8 +3,13 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -33,13 +38,13 @@ fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
isInputValid = false
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
val colorInValid = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.placeholderText
val colorValid = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText
)
OutlinedTextField(
@@ -50,7 +55,7 @@ fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_primary_description),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -68,11 +73,11 @@ fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
placeholder = {
Text(
text = stringResource(R.string.poll_primary_description),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
colors = if (isInputValid) colorValid else colorInValid,
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}

View File

@@ -1,9 +1,9 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -30,13 +30,13 @@ fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) {
label = {
Text(
text = stringResource(R.string.poll_zap_recipients),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_zap_recipients),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}

View File

@@ -7,13 +7,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
@@ -26,13 +24,13 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
val colorInValid = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.placeholderText
val colorValid = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText
)
Row(
@@ -48,13 +46,13 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_zap_value_min),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)
@@ -70,13 +68,13 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_zap_value_max),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)
@@ -88,7 +86,7 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
) {
Text(
text = stringResource(R.string.poll_zap_value_min_max_explainer),
color = MaterialTheme.colors.placeholderText,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
}

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -14,9 +15,13 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.model.*
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.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.relays.Relay
@@ -28,15 +33,23 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.events.findURLs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import java.net.URLEncoder
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
@@ -55,10 +68,13 @@ open class NewPostViewModel() : ViewModel() {
var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<List<Note>?>(null)
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
val imageUploadingError = MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
@@ -88,14 +104,18 @@ open class NewPostViewModel() : ViewModel() {
var isValidConsensusThreshold = mutableStateOf(true)
var isValidClosedAt = mutableStateOf(true)
// Classifieds
var wantsProduct by mutableStateOf(false)
var title by mutableStateOf(TextFieldValue(""))
var price by mutableStateOf(TextFieldValue(""))
var locationText by mutableStateOf(TextFieldValue(""))
var category by mutableStateOf(TextFieldValue(""))
var condition by mutableStateOf<ClassifiedsEvent.CONDITION>(ClassifiedsEvent.CONDITION.USED_LIKE_NEW)
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
data class ForwardZapSetup(val user: User) {
var percentage by mutableStateOf(100)
}
// Forward Zap to
var wantsForwardZapTo by mutableStateOf(false)
var forwardZapTo by mutableStateOf<Split<User>>(Split())
@@ -118,6 +138,9 @@ open class NewPostViewModel() : ViewModel() {
var nip24 by mutableStateOf(false)
open fun load(accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
originalNote = replyingTo
replyingTo?.let { replyNote ->
if (replyNote.event is BaseTextNoteEvent) {
@@ -145,7 +168,7 @@ open class NewPostViewModel() : ViewModel() {
}
quote?.let {
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
}
@@ -161,9 +184,6 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun sendPost(relayList: List<Relay>? = null) {
@@ -207,11 +227,25 @@ open class NewPostViewModel() : ViewModel() {
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
nip95attachments.forEach {
if (replyTos?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) {
account?.sendNip95(it.first, it.second, relayList)
}
}
val urls = findURLs(tagger.message)
val usedAttachments = nip94attachments.filter {
it.urls().intersect(urls).isNotEmpty()
}
usedAttachments.forEach {
account?.sendHeader(it, relayList, { })
}
if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
} else {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
}
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
@@ -269,7 +303,26 @@ open class NewPostViewModel() : ViewModel() {
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash
geoHash,
nip94attachments = usedAttachments
)
} else if (wantsProduct) {
account?.sendClassifieds(
title = title.text,
price = Price(price.text, "SATS", null),
condition = condition,
message = tagger.message,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
location = locationText.text,
category = category.text,
directMentions = tagger.directMentions,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments
)
} else {
// adds markers
@@ -277,6 +330,8 @@ open class NewPostViewModel() : ViewModel() {
(originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root
?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
?: originalNote?.idHex
val replyId = originalNote?.idHex
account?.sendPost(
@@ -291,7 +346,8 @@ open class NewPostViewModel() : ViewModel() {
root = rootId,
directMentions = tagger.directMentions,
relayList = relayList,
geohash = geoHash
geohash = geoHash,
nip94attachments = usedAttachments
)
}
}
@@ -299,7 +355,15 @@ open class NewPostViewModel() : ViewModel() {
cancel()
}
fun upload(galleryUri: Uri, alt: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
fun upload(
galleryUri: Uri,
alt: String?,
sensitiveContent: Boolean,
isPrivate: Boolean = false,
server: ServerOption,
context: Context,
relayList: List<Relay>? = null
) {
isUploadingImage = true
contentToAddUrl = null
@@ -312,33 +376,51 @@ open class NewPostViewModel() : ViewModel() {
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
if (server.isNip95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
}
} else {
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = server,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
viewModelScope.launch(Dispatchers.IO) {
try {
val result = Nip96Uploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server.server,
contentResolver = contentResolver,
onProgress = { }
)
if (!isPrivate) {
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent
)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
noNIP94(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent
)
}
},
onError = {
} catch (e: Exception) {
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e
)
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
imageUploadingError.emit("Failed to upload: ${e.message}")
}
}
)
}
}
},
onError = {
@@ -375,6 +457,10 @@ open class NewPostViewModel() : ViewModel() {
wantsZapraiser = false
zapRaiserAmount = null
wantsProduct = false
condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
price = TextFieldValue("")
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
@@ -521,6 +607,7 @@ open class NewPostViewModel() : ViewModel() {
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
(!wantsProduct || (!title.text.isNullOrBlank() && !price.text.isNullOrBlank() && !category.text.isNullOrBlank())) &&
contentToAddUrl == null
}
@@ -537,56 +624,140 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
FileHeader.prepare(
imageUrl,
mimeType,
alt,
sensitiveContent,
onReady = {
val note = account?.sendHeader(it, relayList = relayList)
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean
) {
// 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()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
return
}
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false
if (note == null) {
message = TextFieldValue(message.text + "\n\n" + imageUrl)
} else {
message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent())
}
nip94attachments = nip94attachments + event
val contentWarning = if (sensitiveContent) "" else null
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
suspend fun noNIP94(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean
) {
// 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 dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Server failed to return a url")
}
return
}
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = { header: FileHeader ->
isUploadingImage = false
val contentWarning = if (sensitiveContent) "" else null
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
fun addInlineMetadataAsNIP54(
imageUrl: String,
dim: String?,
m: String?,
alt: String?,
blurHash: String?,
x: String?,
sensitiveContent: String?
): String {
val extension = listOfNotNull(
m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" },
dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" },
alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" },
blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" },
x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" },
sensitiveContent?.ifBlank { null }?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }
).joinToString("&")
return if (imageUrl.contains("#")) {
"$imageUrl&$extension"
} else {
"$imageUrl#$extension"
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean
) {
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
alt,
sensitiveContent,
null,
onReady = {
val nip95 = account?.createNip95(bytes, headerInfo = it)
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) }
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
isUploadingImage = false
isUploadingImage = false
note?.let {
message = TextFieldValue(message.text + "\n\nnostr:" + it.toNEvent())
note?.let {
message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent())
}
urlPreview = findUrlInMessage()
}
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
@@ -624,6 +795,7 @@ open class NewPostViewModel() : ViewModel() {
override fun onCleared() {
super.onCleared()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
viewModelScope.launch(Dispatchers.IO) {
locUtil?.stop()
}
@@ -684,7 +856,7 @@ open class NewPostViewModel() : ViewModel() {
}
fun updateZapPercentage(index: Int, sliderValue: Float) {
forwardZapTo?.updatePercentage(index, sliderValue)
forwardZapTo.updatePercentage(index, sliderValue)
}
}

View File

@@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -14,16 +13,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.DeleteSweep
@@ -34,9 +23,20 @@ import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
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.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.mutableStateOf
import androidx.compose.runtime.remember
@@ -54,9 +54,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Constants
@@ -65,6 +66,8 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
@@ -81,10 +84,11 @@ import com.vitorpamplona.amethyst.ui.theme.warningColor
import kotlinx.coroutines.launch
import java.lang.Math.round
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "", nav: (String) -> Unit) {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState()
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
postViewModel.load(accountViewModel.account)
@@ -99,9 +103,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
TopAppBar(
title = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = 10.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -119,7 +121,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
Text(stringResource(R.string.default_relays))
}
PostButton(
SaveButton(
onPost = {
postViewModel.create()
onClose()
@@ -135,8 +137,9 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
onClose()
})
},
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { pad ->
@@ -151,10 +154,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
contentPadding = FeedPadding
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
ServerConfig(
@@ -210,7 +210,7 @@ fun ServerConfigHeader() {
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
Spacer(modifier = Modifier.size(5.dp))
@@ -220,7 +220,7 @@ fun ServerConfigHeader() {
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
Spacer(modifier = Modifier.size(5.dp))
@@ -230,7 +230,7 @@ fun ServerConfigHeader() {
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
Spacer(modifier = Modifier.size(5.dp))
@@ -240,7 +240,7 @@ fun ServerConfigHeader() {
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
Spacer(modifier = Modifier.size(2.dp))
@@ -249,7 +249,7 @@ fun ServerConfigHeader() {
}
Divider(
thickness = 0.25.dp
thickness = DividerThickness
)
}
}
@@ -258,6 +258,7 @@ fun ServerConfigHeader() {
@Composable
fun ServerConfigPreview() {
ServerConfigClickableLine(
loadProfilePicture = true,
item = RelaySetupInfo(
url = "nostr.mom",
read = true,
@@ -297,7 +298,6 @@ fun ServerConfig(
nav: (String) -> Unit
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
relayInfo?.let {
@@ -310,8 +310,13 @@ fun ServerConfig(
)
}
val automaticallyShowProfilePicture = remember {
accountViewModel.settings.showProfilePictures.value
}
ServerConfigClickableLine(
item = item,
loadProfilePicture = automaticallyShowProfilePicture,
onToggleDownload = onToggleDownload,
onToggleUpload = onToggleUpload,
onToggleFollows = onToggleFollows,
@@ -324,7 +329,7 @@ fun ServerConfig(
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfo(item.url), it)
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
},
onError = { url, errorCode, exceptionMessage ->
val msg = when (errorCode) {
@@ -334,15 +339,10 @@ fun ServerConfig(
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
}
scope.launch {
Toast
.makeText(
context,
msg,
Toast.LENGTH_SHORT
)
.show()
}
accountViewModel.toast(
context.getString(R.string.unable_to_download_relay_document),
msg
)
}
)
}
@@ -352,6 +352,7 @@ fun ServerConfig(
@Composable
fun ServerConfigClickableLine(
item: RelaySetupInfo,
loadProfilePicture: Boolean,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
@@ -368,7 +369,7 @@ fun ServerConfigClickableLine(
modifier = Modifier.padding(vertical = 5.dp)
) {
Column(Modifier.clickable(onClick = onClick)) {
RenderRelayIcon(iconUrl = item.briefInfo.favIcon, Size55dp)
RenderRelayIcon(iconUrl = item.briefInfo.favIcon, loadProfilePicture, Size55dp)
}
Spacer(modifier = HalfHorzPadding)
@@ -405,7 +406,7 @@ fun ServerConfigClickableLine(
}
Divider(
thickness = 0.25.dp
thickness = DividerThickness
)
}
}
@@ -441,9 +442,9 @@ private fun RenderStatusRow(
}
),
tint = if (item.read) {
MaterialTheme.colors.allGoodColor
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f
)
}
@@ -454,7 +455,7 @@ private fun RenderStatusRow(
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
Icon(
@@ -477,9 +478,9 @@ private fun RenderStatusRow(
}
),
tint = if (item.write) {
MaterialTheme.colors.allGoodColor
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f
)
}
@@ -490,7 +491,7 @@ private fun RenderStatusRow(
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
Icon(
@@ -512,7 +513,7 @@ private fun RenderStatusRow(
}
}
),
tint = if (item.errorCount > 0) MaterialTheme.colors.warningColor else MaterialTheme.colors.allGoodColor
tint = if (item.errorCount > 0) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.allGoodColor
)
Text(
@@ -520,7 +521,7 @@ private fun RenderStatusRow(
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
Icon(
@@ -542,7 +543,7 @@ private fun RenderStatusRow(
}
}
),
tint = if (item.spamCount > 0) MaterialTheme.colors.warningColor else MaterialTheme.colors.allGoodColor
tint = if (item.spamCount > 0) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.allGoodColor
)
Text(
@@ -550,7 +551,7 @@ private fun RenderStatusRow(
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
@@ -571,7 +572,7 @@ private fun RenderActiveToggles(
text = stringResource(id = R.string.active_for),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colors.placeholderText,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 2.dp, end = 5.dp),
fontSize = 14.sp
)
@@ -601,9 +602,9 @@ private fun RenderActiveToggles(
}
),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) {
MaterialTheme.colors.allGoodColor
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f
)
}
@@ -634,9 +635,9 @@ private fun RenderActiveToggles(
}
),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) {
MaterialTheme.colors.allGoodColor
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f
)
}
@@ -667,9 +668,9 @@ private fun RenderActiveToggles(
}
),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) {
MaterialTheme.colors.allGoodColor
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f
)
}
@@ -700,9 +701,9 @@ private fun RenderActiveToggles(
}
),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) {
MaterialTheme.colors.allGoodColor
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f
)
}
@@ -734,9 +735,9 @@ private fun RenderActiveToggles(
}
),
tint = if (item.feedTypes.contains(FeedType.SEARCH)) {
MaterialTheme.colors.allGoodColor
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f
)
}
@@ -767,7 +768,7 @@ private fun FirstLine(
modifier = Modifier
.padding(start = 5.dp, top = 1.dp)
.size(14.dp),
tint = MaterialTheme.colors.allGoodColor
tint = MaterialTheme.colorScheme.allGoodColor
)
}
}
@@ -803,7 +804,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
placeholder = {
Text(
text = "server.com",
color = MaterialTheme.colors.placeholderText,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1
)
},
@@ -817,7 +818,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
modifier = Modifier
.size(Size35dp)
.padding(horizontal = 5.dp),
tint = if (read) MaterialTheme.colors.allGoodColor else MaterialTheme.colors.placeholderText
tint = if (read) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.placeholderText
)
}
@@ -828,7 +829,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
modifier = Modifier
.size(Size35dp)
.padding(horizontal = 5.dp),
tint = if (write) MaterialTheme.colors.allGoodColor else MaterialTheme.colors.placeholderText
tint = if (write) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.placeholderText
)
}
@@ -846,7 +847,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.placeholderText
containerColor = if (url.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.placeholderText
)
) {
Text(text = stringResource(id = R.string.add), color = Color.White)

View File

@@ -32,10 +32,9 @@ class NewRelayListViewModel : ViewModel() {
relays.let {
viewModelScope.launch(Dispatchers.IO) {
account.saveRelayList(it.value)
clear()
}
}
clear()
}
fun loadRelayDocuments() {
@@ -57,21 +56,30 @@ class NewRelayListViewModel : ViewModel() {
_relays.update {
var relayFile = account.userProfile().latestContactList?.relays()
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
if (relayFile?.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } == true) {
relayFile = relayFile + Constants.forcedRelayForSearch.map {
Pair(
it.url,
ContactListEvent.ReadWrite(it.read, it.write)
)
}
}
if (relayFile != null) {
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
val needsSearchRelay = relayFile.none {
it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet
} && relayFile.none {
account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes?.contains(FeedType.SEARCH) ?: false
}
if (needsSearchRelay) {
relayFile = relayFile + Constants.forcedRelayForSearch.map {
Pair(
it.url,
ContactListEvent.ReadWrite(it.read, it.write)
)
}
}
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet().toImmutableSet()
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes
?: Constants.defaultRelays.filter { defaultRelay -> defaultRelay.url == it.key }.firstOrNull()?.feedTypes
?: FeedType.values().toSet().toImmutableSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
@@ -111,7 +119,7 @@ class NewRelayListViewModel : ViewModel() {
fun deleteAll() {
_relays.update { relays ->
relays.dropWhile { relays.isNotEmpty() }
emptyList()
}
}

View File

@@ -11,13 +11,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -38,7 +37,6 @@ import kotlinx.coroutines.withContext
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
val postViewModel: NewUserMetadataViewModel = viewModel()
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
postViewModel.load(account)
@@ -74,7 +72,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
onClose()
})
PostButton(
SaveButton(
onPost = {
postViewModel.create()
onClose()
@@ -88,43 +86,22 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
.padding(10.dp)
.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier.fillMaxWidth(1f),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.display_name)) },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_display_name),
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
Text("@", Modifier.padding(5.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.username)) },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_username),
color = MaterialTheme.colors.placeholderText
)
},
singleLine = true
)
}
OutlinedTextField(
label = { Text(text = stringResource(R.string.display_name)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_display_name),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
@@ -138,7 +115,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(id = R.string.about_me),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -157,13 +134,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "https://mywebsite.com/me.jpg",
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
leadingIcon = {
UploadFromGallery(
isUploading = postViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colors.placeholderText,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp)
) {
postViewModel.uploadForPicture(it, context)
@@ -182,13 +159,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "https://mywebsite.com/mybanner.jpg",
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
leadingIcon = {
UploadFromGallery(
isUploading = postViewModel.isUploadingImageForBanner,
tint = MaterialTheme.colors.placeholderText,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp)
) {
postViewModel.uploadForBanner(it, context)
@@ -207,7 +184,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "https://mywebsite.com",
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
singleLine = true
@@ -223,7 +200,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "_@mywebsite.com",
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
singleLine = true
@@ -238,7 +215,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "me@mylightiningnode.com",
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
},
singleLine = true
@@ -254,7 +231,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.lnurl),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)
@@ -269,7 +246,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.twitter_proof_url_template),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)
@@ -284,7 +261,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.mastodon_proof_url_template),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)
@@ -299,7 +276,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.github_proof_url_template),
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colorScheme.placeholderText
)
}
)

View File

@@ -10,11 +10,13 @@ import androidx.lifecycle.viewModelScope
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.MastodonIdentity
import com.vitorpamplona.quartz.events.TwitterIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
@@ -23,7 +25,7 @@ import java.io.StringWriter
class NewUserMetadataViewModel : ViewModel() {
private lateinit var account: Account
val userName = mutableStateOf("")
// val userName = mutableStateOf("")
val displayName = mutableStateOf("")
val about = mutableStateOf("")
@@ -41,13 +43,13 @@ class NewUserMetadataViewModel : ViewModel() {
var isUploadingImageForPicture by mutableStateOf(false)
var isUploadingImageForBanner by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
val imageUploadingError = MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
fun load(account: Account) {
this.account = account
account.userProfile().let {
userName.value = it.bestUsername() ?: ""
// userName.value = it.bestUsername() ?: ""
displayName.value = it.bestDisplayName() ?: ""
about.value = it.info?.about ?: ""
picture.value = it.info?.picture ?: ""
@@ -82,8 +84,8 @@ class NewUserMetadataViewModel : ViewModel() {
} else {
ObjectMapper().createObjectNode()
}
currentJson.put("name", userName.value.trim())
currentJson.put("username", userName.value.trim())
// currentJson.put("username", userName.value.trim())
currentJson.put("name", displayName.value.trim())
currentJson.put("display_name", displayName.value.trim())
currentJson.put("displayName", displayName.value.trim())
currentJson.put("picture", picture.value.trim())
@@ -128,7 +130,7 @@ class NewUserMetadataViewModel : ViewModel() {
}
fun clear() {
userName.value = ""
// userName.value = ""
displayName.value = ""
about.value = ""
picture.value = ""
@@ -183,23 +185,35 @@ class NewUserMetadataViewModel : ViewModel() {
context.applicationContext,
onReady = { fileUri, contentType, size ->
viewModelScope.launch(Dispatchers.IO) {
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = account.defaultFileServer,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
try {
val result = Nip96Uploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = null,
sensitiveContent = null,
server = account.defaultFileServer,
contentResolver = contentResolver,
onProgress = { }
)
val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
if (url != null) {
onUploading(false)
onUploaded(imageUrl)
},
onError = {
onUploaded(url)
} else {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} catch (e: Exception) {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
},
onError = {

View File

@@ -0,0 +1,74 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.events.EmptyTagList
@Composable
fun NotifyRequestDialog(
title: String,
textContent: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember {
mutableStateOf(defaultBackground)
}
TranslatableRichTextViewer(
textContent,
canPreview = true,
Modifier.fillMaxWidth(),
EmptyTagList,
background,
accountViewModel,
nav
)
},
confirmButton = {
Button(onClick = onDismiss, colors = buttonColors, contentPadding = PaddingValues(horizontal = Size16dp)) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null
)
Spacer(StdHorzSpacer)
Text(stringResource(R.string.error_dialog_button_ok))
}
}
}
)
}

View File

@@ -13,9 +13,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -25,7 +26,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
@@ -42,11 +43,15 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding
@Composable
fun RelayInformationDialog(
onClose: () -> Unit,
relayBriefInfo: RelayBriefInfo,
relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
relayInfo: RelayInformation,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val automaticallyShowProfilePicture = remember {
accountViewModel.settings.showProfilePictures.value
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
@@ -77,6 +82,7 @@ fun RelayInformationDialog(
Column() {
RenderRelayIcon(
relayBriefInfo.favIcon,
automaticallyShowProfilePicture,
Size55dp
)
}
@@ -111,7 +117,15 @@ fun RelayInformationDialog(
Section(stringResource(R.string.contact))
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableEmail(relayInfo.contact ?: "")
relayInfo.contact?.let {
if (it.startsWith("https:")) {
ClickableUrl(urlText = it, url = it)
} else if (it.startsWith("mailto:") || it.contains('@')) {
ClickableEmail(it)
} else {
SectionContent(it)
}
}
}
Section(stringResource(R.string.supports))

View File

@@ -1,61 +1,59 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Surface
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RelayList(
val relay: Relay,
val relayInfo: RelayBriefInfo,
val relayInfo: RelayBriefInfoCache.RelayBriefInfo,
val isSelected: Boolean
)
data class RelayInfoDialog(
val relayBriefInfo: RelayBriefInfo,
val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
val relayInfo: RelayInformation
)
@Composable
fun RelaySelectionDialog(
preSelectedList: List<Relay>,
preSelectedList: ImmutableList<Relay>,
onClose: () -> Unit,
onPost: (list: List<Relay>) -> Unit,
onPost: (list: ImmutableList<Relay>) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var relays by remember {
@@ -63,12 +61,19 @@ fun RelaySelectionDialog(
accountViewModel.account.activeWriteRelays().map {
RelayList(
relay = it,
relayInfo = RelayBriefInfo(it.url),
relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url),
isSelected = preSelectedList.any { relay -> it.url == relay.url }
)
}
)
}
val hasSelectedRelay by remember {
derivedStateOf {
relays.any { it.isSelected }
}
}
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
relayInfo?.let {
@@ -121,21 +126,15 @@ fun RelaySelectionDialog(
SaveButton(
onPost = {
val selectedRelays = relays.filter { it.isSelected }
if (selectedRelays.isEmpty()) {
scope.launch {
Toast.makeText(context, context.getString(R.string.select_a_relay_to_continue), Toast.LENGTH_SHORT).show()
}
return@SaveButton
}
onPost(selectedRelays.map { it.relay })
onPost(selectedRelays.map { it.relay }.toImmutableList())
onClose()
},
isActive = true
isActive = hasSelectedRelay
)
}
RelaySwitch(
text = stringResource(R.string.select_deselect_all),
text = context.getString(R.string.select_deselect_all),
checked = selected,
onClick = {
selected = !selected
@@ -146,10 +145,7 @@ fun RelaySelectionDialog(
)
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
contentPadding = FeedPadding
) {
itemsIndexed(
relays,
@@ -171,7 +167,12 @@ fun RelaySelectionDialog(
accountViewModel.retrieveRelayDocument(
item.relay.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfo(item.relay.url), it)
relayInfo = RelayInfoDialog(
RelayBriefInfoCache.RelayBriefInfo(
item.relay.url
),
it
)
},
onError = { url, errorCode, exceptionMessage ->
val msg = when (errorCode) {
@@ -181,15 +182,10 @@ fun RelaySelectionDialog(
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
}
scope.launch {
Toast
.makeText(
context,
msg,
Toast.LENGTH_SHORT
)
.show()
}
accountViewModel.toast(
context.getString(R.string.unable_to_download_relay_document),
msg
)
}
)
}

View File

@@ -3,12 +3,10 @@ package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -66,21 +64,16 @@ fun SaveToGallery(url: String) {
}
}
Button(
OutlinedButton(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
}
) {
Text(text = stringResource(id = R.string.save), color = Color.White)
Text(text = stringResource(id = R.string.save))
}
}
@@ -126,7 +119,7 @@ fun SaveToGallery(localFile: File, mimeType: String?) {
}
}
Button(
OutlinedButton(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
@@ -134,12 +127,8 @@ fun SaveToGallery(localFile: File, mimeType: String?) {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
shape = ButtonBorder
) {
Text(text = stringResource(id = R.string.save), color = Color.White)
Text(text = stringResource(id = R.string.save))
}
}

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