Compare commits

..
121 Commits
Author SHA1 Message Date
Vitor Pamplona 133d7d5ce0 v0.84.2 2024-02-15 11:00:23 -05:00
Vitor Pamplona 215989108b Fixes misalignment of Clickable Texts 2024-02-15 11:00:15 -05:00
Vitor Pamplona ed7dcf94f8 Fixes extra spaces after urls 2024-02-15 10:59:52 -05:00
Vitor PamplonaandGitHub b2c7f150ec Merge pull request #774 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-15 08:46:02 -05:00
Crowdin Bot c85d74a688 New Crowdin translations by GitHub Action 2024-02-15 13:41:21 +00:00
Vitor PamplonaandGitHub cbf23fedf1 Merge pull request #773 from davotoula/update_translations
Update translations
2024-02-15 08:39:53 -05:00
David Kaspar 5ed64bd5b5 Change to ellipsis character 2024-02-15 12:19:12 +00:00
David Kaspar b79f9c8395 Updated translations. DE, SV, PT, CS 2024-02-15 12:18:19 +00:00
Vitor Pamplona 02da606461 v0.84.1 2024-02-14 17:49:07 -05:00
Vitor Pamplona 7074446cae Removing scrypt native library that doesn't work on Android 2024-02-14 17:48:57 -05:00
Vitor Pamplona 4746b5b18a removes unneeded translation keys 2024-02-14 17:40:50 -05:00
fiatjafandVitor Pamplona 103a61f35c useless pt-BR translation changes. 2024-02-14 17:29:28 -05:00
Vitor Pamplona d9c48d4039 v0.84.0 2024-02-14 17:09:10 -05:00
Vitor Pamplona 9479e86b66 Enabled citation via @ on chats 2024-02-14 16:52:25 -05:00
Vitor PamplonaandGitHub bb7ec1ee4b Merge pull request #772 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-14 12:59:57 -05:00
Crowdin Bot 7a213545a2 New Crowdin translations by GitHub Action 2024-02-14 17:56:37 +00:00
Vitor Pamplona 48f27f656d Adds NIP49 to login and back up key screens 2024-02-14 12:54:08 -05:00
Vitor Pamplona 47176421e0 Adds copyright sentence. 2024-02-14 09:17:39 -05:00
Vitor Pamplona 9c84a538d6 Removes the NSEC form nip19 2024-02-13 20:49:21 -05:00
Vitor Pamplona 8bc1c2ec68 Removing nip19 test with ncryptosec 2024-02-13 20:44:00 -05:00
Vitor Pamplona b94c1e2792 Quartz support for NIP-49 encrypted nsecs 2024-02-13 19:51:32 -05:00
Vitor Pamplona 74f0393706 Adds "₿itcoin" to the set of custom hashtags 2024-02-13 12:42:46 -05:00
Vitor PamplonaandGitHub 0ec4b9b59f Merge pull request #771 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-13 11:58:43 -05:00
Crowdin Bot 1592d55db5 New Crowdin translations by GitHub Action 2024-02-13 16:43:27 +00:00
Vitor Pamplona 629c8b8167 Avoids the memory use of the flatten operation 2024-02-13 11:40:42 -05:00
Vitor Pamplona 9d8f70e04b No need to remember this param. 2024-02-13 11:39:54 -05:00
Vitor Pamplona f75104e616 Updates Compose dependencies 2024-02-13 11:38:55 -05:00
Vitor Pamplona c9d59fd5ac Adds a check for the main thread when pulling opengraph tags. 2024-02-13 11:38:28 -05:00
Vitor Pamplona d41eb4ce31 No need to crossfade when expanding content 2024-02-12 18:11:28 -05:00
Vitor Pamplona 60e87d943a v0.83.13 2024-02-12 16:09:03 -05:00
Vitor Pamplona 1deed59d5b Fixes: https://github.com/vitorpamplona/amethyst/issues/740
Displays error messages when the URI sent to Amethyst is invalid
2024-02-12 16:03:37 -05:00
Vitor Pamplona bbc4ec2625 Fixes thread rendering when "mention" events are added with mentioning the event. 2024-02-12 15:10:48 -05:00
Vitor Pamplona 5109b65e37 Fixes crash when MuteList has e tags that are not valid hexes 2024-02-12 14:21:32 -05:00
Vitor Pamplona c0a9ce8e21 Small refactoring to focus the encrypted storage procedures to the application context. 2024-02-12 11:52:18 -05:00
Vitor Pamplona bdff8c6c0d Moves language translation cleanup to the IO thread since it clears disk files as well 2024-02-12 11:15:59 -05:00
Vitor PamplonaandGitHub a3ac8a445d Merge pull request #768 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-10 10:22:09 -05:00
Crowdin Bot 84d4d68116 New Crowdin translations by GitHub Action 2024-02-10 15:00:43 +00:00
Vitor Pamplona 27248bff17 Invert the order of the hidden users in the screen. 2024-02-10 09:59:01 -05:00
Vitor PamplonaandGitHub dc9a9f288b Merge pull request #767 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-10 08:40:44 -05:00
Crowdin Bot 88662a4e55 New Crowdin translations by GitHub Action 2024-02-10 13:27:57 +00:00
Vitor PamplonaandGitHub 9d269f7cba Merge pull request #766 from davotoula/update_translations
Updated translations. DE, SV, PT
2024-02-10 08:26:53 -05:00
David Kaspar 04fe439e0b Updated translations. DE, SV, PT 2024-02-10 09:45:17 +00:00
Vitor Pamplona 72503b9061 Adds community and public chat list events from nip51 2024-02-09 15:54:56 -05:00
Vitor Pamplona f7ab963db9 Small fixes in the use of modifiers 2024-02-09 14:37:49 -05:00
Vitor Pamplona f97128e11c Fixing accessibility issues raised on https://github.com/vitorpamplona/amethyst/issues/765 2024-02-08 17:16:54 -05:00
Vitor Pamplona e2d4107297 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-08 14:54:04 -05:00
Vitor Pamplona 18709a1b22 Disables the notification dot in the discovery tab 2024-02-08 14:53:53 -05:00
Vitor Pamplona ddfe2abb42 Fixes send to Top in marketplaces. 2024-02-08 14:53:35 -05:00
Vitor PamplonaandGitHub 0cf82f7edd Merge pull request #764 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-07 16:37:18 -05:00
Crowdin Bot d1c40efd90 New Crowdin translations by GitHub Action 2024-02-07 21:16:44 +00:00
Vitor Pamplona 68e90ce3ed Unwrapps the reply message if the GiftWrap was tagged and not the inner message id. 2024-02-07 16:14:48 -05:00
Vitor Pamplona 95e19cf681 v0.83.12 2024-02-05 20:29:43 -05:00
Vitor Pamplona 3298fd4493 BugFix for short lists 2024-02-05 20:26:37 -05:00
Vitor Pamplona 248bdffd50 v0.83.11 2024-02-05 19:49:27 -05:00
Vitor Pamplona cce7a20918 Speeds up parsing of the event content. 2024-02-05 19:47:33 -05:00
Vitor Pamplona 845c99b798 Faster Expandable Text calculator. 2024-02-05 19:37:21 -05:00
Vitor Pamplona b13b832696 Starting a new module for app benchmarks 2024-02-05 18:18:33 -05:00
Vitor Pamplona eb6e235f1b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-05 16:52:57 -05:00
Vitor Pamplona 36c49b2171 Updates Zoomable and Zxing Qr Reader 2024-02-05 16:52:48 -05:00
Vitor PamplonaandGitHub 5cd3096221 Merge pull request #761 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-05 12:43:57 -05:00
Crowdin Bot 5bdc59d0e3 New Crowdin translations by GitHub Action 2024-02-05 17:37:48 +00:00
Vitor Pamplona be43f2025f v0.83.10 2024-02-05 12:35:42 -05:00
Vitor PamplonaandGitHub 0bb39f91b9 README typos and formatting 2024-02-02 18:53:36 -05:00
Vitor Pamplona 63a7b44264 Adds crowdin address to the readme 2024-02-02 18:44:10 -05:00
Vitor Pamplona b5341f67e0 Finally fixes: https://github.com/vitorpamplona/amethyst/issues/436 2024-02-02 18:23:44 -05:00
Vitor Pamplona 3a2045eccc Allows buttons in the New Post screen to scroll horizontally to partially fix https://github.com/vitorpamplona/amethyst/issues/723
We are waiting the default scrollbar implementation from Jetpack Compose
2024-02-02 17:39:48 -05:00
Vitor Pamplona c78239a6b7 Fixes some connectivity issues by basing the timeout from the last connection tentative instead of the drop out time. 2024-02-02 16:08:36 -05:00
Vitor PamplonaandGitHub 4be4306540 Merge pull request #759 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-02 10:27:22 -05:00
Crowdin Bot 48887f23c1 New Crowdin translations by GitHub Action 2024-02-02 14:37:01 +00:00
Vitor Pamplona 103809d35b Fixes the top padding of the quoted note in a post. 2024-02-02 09:35:17 -05:00
Vitor Pamplona 9d92976eb9 Optimizes memory use of the visual user and url tagger in new posts. 2024-02-02 09:30:36 -05:00
Vitor Pamplona d3d19b497b Renames from NIP-29 to 92 2024-02-01 19:20:26 -05:00
Vitor Pamplona 316c32f076 v0.83.9 2024-02-01 18:38:25 -05:00
Vitor Pamplona 6f27201244 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-01 18:31:36 -05:00
Vitor Pamplona e56377f8c3 Adds support for NIP-29 in public messages and new DMs. NIP-54 stays in NIP-54 2024-02-01 18:31:28 -05:00
Vitor PamplonaandGitHub 44e04864ac Merge pull request #758 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-01 16:14:42 -05:00
Crowdin Bot eea727570c New Crowdin translations by GitHub Action 2024-02-01 20:57:56 +00:00
Vitor Pamplona 54155a3c30 Includes the product in the first message of the marketplace. 2024-02-01 15:56:13 -05:00
Vitor Pamplona 34d373c293 Updates to Android Studio Hedgehog | 2023.1.1 Patch 2 2024-01-31 16:05:00 -05:00
Vitor PamplonaandGitHub d7563032c4 Merge pull request #756 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-31 14:37:59 -05:00
Crowdin Bot ed75489975 New Crowdin translations by GitHub Action 2024-01-31 19:29:03 +00:00
Vitor Pamplona 145b0d8f0a v0.83.8 2024-01-31 14:26:58 -05:00
Vitor Pamplona eae07e2fe0 Making sure Cancellation of coroutines stops long processes. 2024-01-30 17:33:44 -05:00
Vitor Pamplona 6b60b1434f Name refactoring 2024-01-30 13:52:02 -05:00
Vitor Pamplona 3ff4db264a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-30 13:40:08 -05:00
Vitor Pamplona 21f042fa95 Fixes secondary buttons theme in the login and signup screens. 2024-01-30 13:38:41 -05:00
Vitor PamplonaandGitHub e26bbc2e61 Merge pull request #755 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-30 13:16:43 -05:00
Crowdin Bot 5135026e6a New Crowdin translations by GitHub Action 2024-01-30 18:12:06 +00:00
Vitor Pamplona a72f5ae01f Removing unnecessary logs 2024-01-30 13:09:43 -05:00
Vitor PamplonaandGitHub f2ed2d94c4 Update README.md
Fixing Markdown
2024-01-30 12:01:23 -05:00
Vitor Pamplona 75d703ac0a Testing GitStr with a Readme patch 2024-01-30 11:54:01 -05:00
Vitor Pamplona 277bf0a603 Fixes misalignment of some links to users in the middle of the note. 2024-01-30 11:51:02 -05:00
Vitor Pamplona 1ac990b67c Set account to avoid lateinit property account has not been initialized 2024-01-30 11:20:25 -05:00
Vitor Pamplona e59f676d47 updates secp256k1 2024-01-30 11:00:33 -05:00
Vitor Pamplona 312d82fdf6 moving the order or contributing 2024-01-30 09:47:31 -05:00
Vitor Pamplona 08659738bd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-30 09:46:06 -05:00
Vitor PamplonaandGitHub f1b0958903 Merge pull request #754 from davotoula/fix_gr_locale
Correct GR locale config
2024-01-30 09:45:36 -05:00
Vitor Pamplona 998aa2aed1 Adding GitStr 2024-01-30 09:44:48 -05:00
David Kaspar 370a18f716 Correct GR locale config 2024-01-30 13:30:25 +00:00
Vitor Pamplona 6638defbb9 Fixes content sensitivity for NIP-54 images. 2024-01-29 18:26:25 -05:00
Vitor Pamplona d88eb014b7 Adding the exception to the Zap error message. 2024-01-29 17:14:31 -05:00
Vitor Pamplona 14e9a91d40 Updates dependencies 2024-01-29 16:28:53 -05:00
Vitor Pamplona 3ed45f282c Fixing NIP number for wraps/seals 2024-01-29 11:19:56 -05:00
Vitor Pamplona 2ff090dac1 New NIP available 2024-01-29 11:03:15 -05:00
Vitor Pamplona 281d86b4bf Small refactoring on the HTTP client 2024-01-27 11:58:39 -05:00
Vitor PamplonaandGitHub 504e55a93f Merge pull request #753 from greenart7c3/proxy_fix
Recreate http client when changing the proxy or timeout
2024-01-27 11:39:29 -05:00
greenart7c3 c7b2c20119 recreate httpclient when changing the proxy or timeout 2024-01-24 17:18:25 -03:00
Vitor PamplonaandGitHub 3e102c064b Merge pull request #750 from greenart7c3/queries
remove the need for application package name in the androidManifest for the external signer
2024-01-23 09:22:58 -05:00
greenart7c3 e0ec8a741c remove the need for application package name in the androidManifest for the external signer 2024-01-22 11:29:10 -03:00
Vitor PamplonaandGitHub f1388b46d5 Merge pull request #746 from greenart7c3/main
add a copy url to the url preview card
2024-01-19 06:07:42 -05:00
greenart7c3 0f24e458d8 add a copy url to the url preview card 2024-01-19 07:18:25 -03:00
Vitor Pamplona b8f3625954 Fixes remember of the wrong hashtag because it's a different post. 2024-01-18 15:57:21 -03:00
Vitor Pamplona 0babc46f7c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-18 13:36:24 -03:00
Vitor Pamplona 6d368148cc makes the benchmark module profileable 2024-01-18 13:35:53 -03:00
Vitor Pamplona 3241f0ada1 formatting 2024-01-18 13:35:38 -03:00
Vitor Pamplona 49ad972af8 activates nostr.mom and nos.lol in the default relay set for global because of the Video feed. 2024-01-18 13:34:30 -03:00
Vitor Pamplona e061575454 Removes unused imports 2024-01-18 13:33:48 -03:00
Vitor PamplonaandGitHub 694642b42b Merge pull request #745 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-17 07:27:51 -05:00
Crowdin Bot f4684ed078 New Crowdin translations by GitHub Action 2024-01-17 12:24:41 +00:00
Vitor PamplonaandGitHub db12ff0532 Merge pull request #744 from greenart7c3/reject_permission_amber
add support for always rejected permissions from external signer
2024-01-17 07:23:13 -05:00
greenart7c3 13d9c1ab10 add support for always rejected permissions from external signer 2024-01-17 09:05:24 -03:00
186 changed files with 4753 additions and 1267 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.10" />
<option name="version" value="1.9.22" />
</component>
</project>
+25 -15
View File
@@ -2,7 +2,7 @@
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px"/>
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
[<img src="./docs/design/obtainium.png"
alt="Get it on Obtaininum"
@@ -27,7 +27,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09)
- [x] Replies, mentions, Threads and Notifications (NIP-10)
- [x] Replies, mentions, Threads, and Notifications (NIP-10)
- [x] Relay Information Document (NIP-11)
- [x] Generic Tag Queries (NIP-12)
- [x] Proof of Work Display (NIP-13)
@@ -59,6 +59,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47)
- [ ] Proxy Tags (NIP-48, Not applicable)
- [x] Private key encryption for import/export (NIP-49)
- [x] Online Relay Search (NIP-50)
- [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52)
@@ -69,6 +70,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Zaps (NIP-57)
- [x] Private Zaps
- [x] Zap Splits (NIP-57)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Zapraiser (NIP-TBD)
- [x] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65)
@@ -79,13 +81,13 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Highlights (NIP-84)
- [x] Recommended Application Handlers (NIP-89)
- [ ] Data Vending Machine (NIP-90)
- [x] Inline Metadata (NIP-92)
- [x] Verifiable file URLs (NIP-94)
- [x] Binary Blobs (NIP-95)
- [x] HTTP File Storage Integration (NIP-96 Draft)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Private Messages and Small Groups (NIP-24/Draft)
- [x] Gift Wraps & Seals (NIP-59/Draft)
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
- [x] Audio Tracks (zapstr.live) (Kind:31337)
- [x] Push Notifications (Google and Unified Push)
@@ -103,7 +105,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
# Development Overview
## Overall Architecture
## Overall Architecture
This is a native Android app made with Kotlin and Jetpack Compose.
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
@@ -111,13 +113,13 @@ The overall architecture consists of the UI, which uses the usual State/ViewMode
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
allow the UI and other parts of the app to subscribe to each individual Note/User and receive updates when they happen.
allow the UI and other parts of the app to subscribe to each Note/User and receive updates when they happen.
They are also responsible for updating viewModels when needed. Filters react to changes in the screen. As the user
sees different Events, the Datasource classes are used to receive more information about those particular Events.
Most of the UI is reactive to changes in the repository classes. The service layer assembles Nostr filters for each need of the app,
receives the data from the Relay, and sends it to the repository. Connection with relays is never closed during the use of the app.
The UI receives a notification that objects were updated. Instances of User and Notes are mutable directly.
The UI receives a notification that objects have been updated. Instances of User and Notes are mutable directly.
There will never be two Notes with the same ID or two User instances with the same pubkey.
Lastly, the user's account information (priv key/pub key) is stored in the Android KeyStore for security.
@@ -129,7 +131,7 @@ Make sure to have the following pre-requisites installed:
2. Android Studio
3. Android 8.0+ Phone or Emulation setup
Fork and clone this repository and import into Android Studio
Fork and clone this repository and import it into Android Studio
```bash
git clone https://github.com/vitorpamplona/amethyst.git
```
@@ -166,6 +168,18 @@ For the Play build:
./gradlew installPlayDebug
```
## Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also very welcome.
Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
By contributing to this repository, you agree to license your work under the MIT license. Any work
contributed where you are not the original author must contain its license header with the original
author(s) and source.
## How to Deploy
1. Generate a new signing key
@@ -183,10 +197,10 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
5. Tag the commit with `v{x.x.x}`
6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file.
7. Add your CHANGE LOG to the description of the new release
8. Download the `aab` file and upload it to the` PlayStore.
8. Download the `aab` file and upload it to the PlayStore.
# Privacy on Relays & nostr
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
@@ -197,11 +211,11 @@ While the content of direct messages (DMs) is only visible to you and your DM co
# Visibility & Permanence of Your Content on nostr
## Information Visibility ##
Content that you share can be shared to other relays.
Content that you share can be shared to other relays.
Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
## Information Permanence ##
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
# Screenshots
@@ -209,10 +223,6 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |
# Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
## Contributors
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
+14 -10
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 348
versionName "0.83.7"
versionCode 357
versionName "0.84.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -26,7 +26,7 @@ android {
'cy-rGB',
'da-rDK',
'de',
'el-rGB',
'el-rGR',
'en-rGB',
'eo',
'es',
@@ -99,6 +99,7 @@ android {
}
create("benchmark") {
initWith(getByName("release"))
profileable true
signingConfig signingConfigs.debug
}
}
@@ -140,13 +141,15 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.5.3"
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.8"
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
exclude '**/libscrypt.dylib'
}
lint {
@@ -160,6 +163,7 @@ android {
dependencies {
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.8.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
@@ -180,7 +184,7 @@ dependencies {
// Adaptive Layout / Two Pane
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
implementation "com.google.accompanist:accompanist-adaptive:0.32.0"
implementation 'com.google.accompanist:accompanist-adaptive:0.34.0'
// Lifecycle
@@ -190,7 +194,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation 'net.engawapg.lib:zoomable:1.5.3'
implementation 'net.engawapg.lib:zoomable:1.6.0'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -227,7 +231,7 @@ dependencies {
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// For QR generation
implementation 'com.google.zxing:core:3.5.2'
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
@@ -250,7 +254,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.2'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.7.0')
playImplementation platform('com.google.firebase:firebase-bom:32.7.2')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
//PushNotifications(FDroid)
@@ -275,8 +279,8 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.9'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha03'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha03'
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"
+5
View File
@@ -33,6 +33,11 @@
# JNA For Libsodium
-keep class com.goterl.lazysodium.** { *; }
# libscrypt
-keep class com.lambdaworks.codec.** { *; }
-keep class com.lambdaworks.crypto.** { *; }
-keep class com.lambdaworks.jni.** { *; }
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
-keep class com.sun.jna.ToNativeConverter { *; }
-keep class com.sun.jna.NativeMapped { *; }
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -60,6 +61,7 @@ class PushMessageReceiver : MessagingReceiver() {
try {
parseMessage(messageStr)?.let { receiveIfNew(it) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message could not be parsed: ${e.message}")
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo
import kotlinx.coroutines.CancellationException
object PushNotificationUtils {
var hasInit: Boolean = false
@@ -36,6 +37,7 @@ object PushNotificationUtils {
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
}
}
+5 -2
View File
@@ -3,8 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.torproject.android"/>
<package android:name="com.greenart7c3.nostrsigner"/>
<package android:name="com.greenart7c3.nostrsigner.debug"/>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent>
</queries>
<!-- Doesn't require a camera -->
@@ -26,6 +26,7 @@ import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader
import coil.disk.DiskCache
import com.vitorpamplona.amethyst.service.playback.VideoCache
@@ -87,6 +88,10 @@ class Amethyst : Application() {
return ImageLoader.Builder(applicationContext).diskCache { imageCache }
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {
return EncryptedStorage.preferences(instance, npub)
}
companion object {
lateinit var instance: Amethyst
private set
@@ -20,32 +20,35 @@
*/
package com.vitorpamplona.amethyst
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
class EncryptedStorage {
companion object {
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"
}
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
}
fun preferences(npub: String? = null): EncryptedSharedPreferences {
val context = Amethyst.instance
val masterKey: MasterKey =
MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
fun preferences(
applicationContext: Context,
npub: String? = null,
): EncryptedSharedPreferences {
val masterKey: MasterKey =
MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val preferencesName = prefsFileName(npub)
return EncryptedSharedPreferences.create(
context,
preferencesName,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) as EncryptedSharedPreferences
return EncryptedSharedPreferences.create(
applicationContext,
prefsFileName(npub),
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) as EncryptedSharedPreferences
}
}
}
@@ -40,7 +40,7 @@ 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.HttpClientManager
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.KeyPair
@@ -53,6 +53,7 @@ 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.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
@@ -232,7 +233,7 @@ object LocalPreferences {
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
return EncryptedStorage.preferences(npub)
return Amethyst.instance.encryptedStorage(npub)
}
}
@@ -368,6 +369,7 @@ object LocalPreferences {
return try {
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue<Settings>(it) }
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}",
@@ -510,6 +512,7 @@ object LocalPreferences {
}
?: Nip96MediaServers.DEFAULT[0]
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
e.printStackTrace()
Nip96MediaServers.DEFAULT[0]
@@ -521,6 +524,7 @@ object LocalPreferences {
Event.mapper.readValue<Nip47URI?>(it)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}",
@@ -541,6 +545,7 @@ object LocalPreferences {
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
@@ -556,6 +561,7 @@ object LocalPreferences {
}
?: mapOf()
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}",
@@ -570,7 +576,7 @@ object LocalPreferences {
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
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 proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
@@ -588,6 +594,7 @@ object LocalPreferences {
}
?: mapOf()
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}",
@@ -30,7 +30,7 @@ import coil.decode.SvgDecoder
import coil.size.Precision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -51,12 +51,12 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.coroutines.CancellationException
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
@@ -84,7 +84,7 @@ class ServiceManager {
val myAccount = account
// Resets Proxy Use
HttpClient.start(account?.proxy)
HttpClientManager.setDefaultProxy(account?.proxy)
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
Amethyst.instance
@@ -97,7 +97,7 @@ class ServiceManager {
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT)
.respectCacheHeaders(false)
.build()
@@ -127,6 +127,7 @@ class ServiceManager {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
@@ -1325,7 +1325,7 @@ class Account(
directMentions: Set<HexKey>,
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1381,7 +1381,7 @@ class Account(
zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1428,7 +1428,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1461,7 +1461,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1495,6 +1495,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
sendPrivateMessage(
message,
@@ -1505,6 +1506,7 @@ class Account(
wantsToMarkAsSensitive,
zapRaiserAmount,
geohash,
nip94attachments,
)
}
@@ -1517,6 +1519,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1533,6 +1536,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
advertiseNip18 = false,
) {
@@ -1551,6 +1555,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1567,6 +1572,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
) {
broadcastPrivately(it)
@@ -32,6 +32,7 @@ fun checkForHashtagWithIcon(
primary: Color,
): HashtagIcon? {
return when (tag.lowercase()) {
"₿itcoin",
"bitcoin",
"btc",
"timechain",
@@ -50,6 +50,7 @@ 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.ChannelListEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
@@ -57,6 +58,7 @@ 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.CommunityListEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
@@ -98,6 +100,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -462,6 +465,20 @@ object LocalCache {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: CommunityListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: ChannelListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: FileServersEvent,
relay: Relay?,
@@ -1397,6 +1414,7 @@ object LocalCache {
try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
@@ -1446,6 +1464,7 @@ object LocalCache {
try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
@@ -1738,6 +1757,7 @@ object LocalCache {
try {
event.checkSignature()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson())
}
false
@@ -1768,6 +1788,7 @@ object LocalCache {
is CalendarTimeSlotEvent -> consume(event, relay)
is CalendarRSVPEvent -> consume(event, relay)
is ChannelCreateEvent -> consume(event)
is ChannelListEvent -> consume(event, relay)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
@@ -1775,6 +1796,7 @@ object LocalCache {
is ChatMessageEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event, relay)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityListEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event)
@@ -1831,6 +1853,7 @@ object LocalCache {
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
}
}
@@ -63,6 +63,7 @@ import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@@ -614,7 +615,7 @@ open class Note(val idHex: String) {
// Regular Zap Receipts
zaps.forEach {
val noteEvent = it?.value?.event
val noteEvent = it.value?.event
if (noteEvent is LnZapEvent) {
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
}
@@ -644,6 +645,7 @@ open class Note(val idHex: String) {
try {
LnInvoiceUtil.getAmountInSats(invoice)
} catch (e: java.lang.Exception) {
if (e is CancellationException) throw e
null
}
@@ -694,6 +696,7 @@ open class Note(val idHex: String) {
try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
if (e is CancellationException) throw e
null
// do nothing if it can't convert to bigdecimal
}
@@ -709,6 +712,7 @@ open class Note(val idHex: String) {
try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
if (e is CancellationException) throw e
null
// do nothing if it can't convert to bigdecimal
}
@@ -34,6 +34,9 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions
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.encoders.Nip54
import com.vitorpamplona.quartz.encoders.Nip92
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -42,6 +45,8 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import java.util.regex.Pattern
@Immutable
@@ -93,25 +98,33 @@ val HTTPRegex =
.toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() {
fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? {
fun parseMediaUrl(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
): ZoomableUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
val frags = Nip54().parse(fullUrl)
val tags = Nip92().parse(fullUrl, eventTags.lists)
ZoomableUrlImage(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"],
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
val frags = Nip54().parse(fullUrl)
val tags = Nip92().parse(fullUrl, eventTags.lists)
ZoomableUrlVideo(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"],
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
)
} else {
null
@@ -143,7 +156,7 @@ class RichTextParser() {
}
val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url }
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
@@ -167,34 +180,34 @@ class RichTextParser() {
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()
val lines = content.split('\n')
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
content.split('\n').forEach { paragraph ->
var segments = persistentListOf<Segment>()
lines.forEach { paragraph ->
var isDirty = false
val isRTL = isArabic(paragraph)
val wordList = paragraph.trimEnd().split(' ')
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
isDirty = true
}
segments = segments.add(wordSegment)
segments.add(wordSegment)
}
val newSegments =
if (isDirty) {
ParagraphState(segments, isRTL)
ParagraphState(segments.toPersistentList(), isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
paragraphSegments.add(newSegments)
}
return paragraphSegments
return paragraphSegments.toImmutableList()
}
fun isNumber(word: String): Boolean {
@@ -244,7 +257,7 @@ class RichTextParser() {
parseHash(word, tags)
} else if (word.contains(".") && schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val additionalChars = schemelessMatcher.group(4).ifEmpty { null } // additional chars
val pattern =
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
.toRegex(RegexOption.IGNORE_CASE)
@@ -283,6 +296,7 @@ class RichTextParser() {
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
@@ -293,10 +307,11 @@ class RichTextParser() {
if (hashtagMatcher.find()) {
val hashtag = hashtagMatcher.group(1)
if (hashtag != null) {
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
return HashTagSegment(word, hashtag, hashtagMatcher.group(2).ifEmpty { null })
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
}
@@ -32,6 +32,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
import kotlin.coroutines.cancellation.CancellationException
@Immutable
data class CashuToken(
@@ -59,6 +60,7 @@ class CashuProcessor {
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
}
@@ -119,7 +121,7 @@ class CashuProcessor {
checkNotInMainThread()
try {
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
@@ -154,6 +156,7 @@ class CashuProcessor {
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
@@ -170,7 +173,7 @@ class CashuProcessor {
context: Context,
) {
try {
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
@@ -211,6 +214,7 @@ class CashuProcessor {
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
@@ -32,6 +32,7 @@ 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 kotlinx.coroutines.CancellationException
import java.io.IOException
import kotlin.math.roundToInt
@@ -59,6 +60,7 @@ class FileHeader(
onError(null)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError(e.message)
}
@@ -174,6 +176,7 @@ class FileHeader(
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError(e.message)
}
@@ -32,14 +32,13 @@ 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)
object HttpClientManager {
val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L)
val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L)
var proxyChangeListeners = ArrayList<() -> Unit>()
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
var defaultHttpClient: OkHttpClient? = null
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
private var defaultHttpClient: OkHttpClient? = null
// fires off every time value of the property changes
private var internalProxy: Proxy? by
@@ -49,26 +48,34 @@ object HttpClient {
}
}
fun start(proxy: Proxy?) {
fun setDefaultProxy(proxy: Proxy?) {
if (internalProxy != proxy) {
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
this.internalProxy = proxy
this.defaultHttpClient = getHttpClient()
// recreates singleton
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
}
}
fun changeTimeouts(timeout: Duration) {
fun setDefaultTimeout(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout")
if (this.defaultTimeout.seconds != timeout.seconds) {
this.defaultTimeout = timeout
this.defaultHttpClient = getHttpClient()
// recreates singleton
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
}
}
fun getHttpClient(timeout: Duration): OkHttpClient {
val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds
private fun buildHttpClient(
proxy: Proxy?,
timeout: Duration,
): OkHttpClient {
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(internalProxy)
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
@@ -91,24 +98,13 @@ object HttpClient {
}
}
fun getHttpClientForRelays(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout)
}
return defaultHttpClient!!
}
fun getHttpClient(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout)
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
}
return defaultHttpClient!!
}
fun getProxy(): Proxy? {
return internalProxy
}
fun initProxy(
useProxy: Boolean,
hostname: String,
@@ -29,6 +29,7 @@ import android.location.LocationManager
import android.os.HandlerThread
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(context: Context) {
@@ -105,6 +106,7 @@ class ReverseGeoLocationUtil {
.joinToString(", ")
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
return null
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Call
@@ -64,7 +65,7 @@ class Nip05NostrAddressVerifier() {
.url(url)
.build()
HttpClient.getHttpClient()
HttpClientManager.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
@@ -96,7 +97,8 @@ class Nip05NostrAddressVerifier() {
}
},
)
} catch (e: java.lang.Exception) {
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not resolve '$url': ${e.message}")
}
}
@@ -122,7 +124,8 @@ class Nip05NostrAddressVerifier() {
val nip05url =
try {
mapper.readTree(it.lowercase())
} catch (t: Throwable) {
} catch (e: Throwable) {
if (e is CancellationException) throw e
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.model.RelayInformation
import kotlinx.coroutines.CancellationException
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
@@ -84,7 +85,7 @@ class Nip11Retriever {
val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
HttpClient.getHttpClient()
HttpClientManager.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
@@ -102,6 +103,7 @@ class Nip11Retriever {
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay $dirtyUrl in not parseable: $body",
@@ -122,6 +124,7 @@ class Nip11Retriever {
},
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
@@ -24,6 +24,7 @@ import android.net.Uri
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.coroutines.CancellationException
// Rename to the corect nip number when ready.
object Nip47WalletConnectParser {
@@ -42,6 +43,7 @@ object Nip47WalletConnectParser {
try {
decodePublicKey(pubkey).toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
@@ -24,6 +24,7 @@ import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.coroutines.CancellationException
import okhttp3.Request
object Nip96MediaServers {
@@ -87,7 +88,7 @@ class Nip96Retriever {
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
HttpClient.getHttpClient().newCall(request).execute().use { response ->
HttpClientManager.getHttpClient().newCall(request).execute().use { response ->
checkNotInMainThread()
response.use {
val body = it.body.string()
@@ -100,6 +101,7 @@ class Nip96Retriever {
)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e
}
@@ -128,7 +128,7 @@ class Nip96Uploader(val account: Account?) {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
@@ -194,7 +194,7 @@ class Nip96Uploader(val account: Account?) {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
val requestBuilder = Request.Builder()
@@ -227,7 +227,7 @@ class Nip96Uploader(val account: Account?) {
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit,
): PartialEvent {
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
@@ -248,19 +248,11 @@ abstract class NostrDataSource(val debugName: String) {
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
}
} else {
// hasn't changed, does nothing.
if (active) {
Log.d(
this@NostrDataSource.javaClass.simpleName,
"Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
)
Client.sendFilterOnlyIfDisconnected(
updatedSubscription.id,
updatedSubscriptionNewFilters,
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlin.coroutines.cancellation.CancellationException
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
private var searchString: String? = null
@@ -67,6 +68,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
@@ -25,6 +25,7 @@ import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Request
import kotlin.coroutines.cancellation.CancellationException
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
@@ -59,13 +60,14 @@ object OnlineChecker {
.build()
val result =
HttpClient.getHttpClient().newCall(request).execute().use {
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
it.isSuccessful
}
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
if (e is CancellationException) throw e
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.content.Intent
import android.net.Uri
object PackageUtils {
private fun isPackageInstalled(
@@ -36,7 +38,13 @@ object PackageUtils {
return isPackageInstalled(context, "org.torproject.android")
}
fun isAmberInstalled(context: Context): Boolean {
return isPackageInstalled(context, "com.greenart7c3.nostrsigner")
fun isExternalSignerInstalled(context: Context): Boolean {
val intent =
Intent().apply {
action = Intent.ACTION_VIEW
data = Uri.parse("nostrsigner:")
}
val infos = context.packageManager.queryIntentActivities(intent, 0)
return infos.size > 0
}
}
@@ -24,7 +24,7 @@ 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.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
@@ -33,9 +33,10 @@ import okhttp3.Request
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
class LightningAddressResolver() {
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
@@ -96,14 +97,16 @@ class LightningAddressResolver() {
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
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,
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
url,
lnaddress,
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
),
)
}
@@ -183,6 +186,7 @@ class LightningAddressResolver() {
try {
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
@@ -218,6 +222,7 @@ class LightningAddressResolver() {
try {
mapper.readTree(it)
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
@@ -25,8 +25,9 @@ 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.amethyst.service.HttpClientManager
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -106,10 +107,11 @@ class RegisterAccounts(
.post(body)
.build()
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
} catch (e: java.lang.Exception) {
if (e is CancellationException) throw e
val tag =
if (BuildConfig.FLAVOR == "play") {
"FirebaseMsgService"
@@ -28,6 +28,7 @@ import android.util.LruCache
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.CancellationException
object PlaybackClientController {
val cache = LruCache<Int, SessionToken>(1)
@@ -62,12 +63,14 @@ object PlaybackClientController {
try {
onReady(controllerFuture.get())
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
MoreExecutors.directExecutor(),
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
}
@@ -31,7 +31,7 @@ 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
import com.vitorpamplona.amethyst.service.HttpClientManager
@UnstableApi // Extend MediaSessionService
class PlaybackService : MediaSessionService() {
@@ -42,12 +42,12 @@ class PlaybackService : MediaSessionService() {
private var managerLocal: MultiPlayerPlaybackManager? = null
fun newHslDataSource(): MediaSource.Factory {
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
}
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()),
(applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()),
)
}
@@ -90,7 +90,7 @@ class PlaybackService : MediaSessionService() {
Log.d("Lifetime Event", "PlaybackService.onCreate")
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
HttpClientManager.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
private fun onProxyUpdated() {
@@ -114,7 +114,7 @@ class PlaybackService : MediaSessionService() {
override fun onDestroy() {
Log.d("Lifetime Event", "PlaybackService.onDestroy")
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.previews
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -29,6 +30,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
try {
fetch(timeOut)
} catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
}
@@ -20,7 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType
@@ -90,7 +91,8 @@ suspend fun getDocument(
): UrlInfoItem =
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).get().build()
HttpClient.getHttpClient().newCall(request).execute().use {
HttpClientManager.getHttpClient().newCall(request).execute().use {
checkNotInMainThread()
if (it.isSuccessful) {
val mimeType =
it.headers.get("Content-Type")?.toMediaType()
@@ -62,8 +62,8 @@ object Constants {
write = true,
feedTypes = activeTypesChats,
),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
// Less Reliable
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true,
// feedTypes = activeTypes),
@@ -23,7 +23,7 @@ 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.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.Event
@@ -31,11 +31,11 @@ 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 kotlinx.coroutines.CancellationException
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.lang.StringBuilder
import java.util.concurrent.atomic.AtomicBoolean
enum class FeedType {
@@ -63,7 +63,7 @@ class Relay(
const val RECONNECTING_IN_SECONDS = 60 * 3
}
private val httpClient = HttpClient.getHttpClientForRelays()
private val httpClient = HttpClientManager.getHttpClient()
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
@@ -77,7 +77,7 @@ class Relay(
var errorCounter = 0
var pingInMs: Long? = null
var closingTimeInSeconds = 0L
var lastConnectTentative: Long = 0L
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
@@ -107,7 +107,7 @@ class Relay(
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Relay", "Relay.connect $url")
Log.d("Relay", "Relay.connect $url hasProxy: ${this.httpClient.proxy != null}")
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
@@ -120,6 +120,8 @@ class Relay(
if (socket != null) return
lastConnectTentative = TimeUtils.now()
try {
val request =
Request.Builder()
@@ -129,6 +131,8 @@ class Relay(
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
} catch (e: Exception) {
if (e is CancellationException) throw e
errorCounter++
markConnectionAsClosed()
Log.e("Relay", "Relay Invalid $url")
@@ -168,8 +172,9 @@ class Relay(
try {
processNewRelayMessage(text)
} catch (t: Throwable) {
t.printStackTrace()
} catch (e: Throwable) {
if (e is CancellationException) throw e
e.printStackTrace()
text.chunked(2000) { chunked ->
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
}
@@ -248,7 +253,6 @@ class Relay(
this.isReady = false
this.usingCompression = false
this.resetEOSEStatuses()
this.closingTimeInSeconds = TimeUtils.now()
}
fun processNewRelayMessage(newMessage: String) {
@@ -328,7 +332,7 @@ class Relay(
Log.d("Relay", "Relay.disconnect $url")
checkNotInMainThread()
closingTimeInSeconds = TimeUtils.now()
lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested.
socket?.cancel()
socket = null
isReady = false
@@ -370,7 +374,7 @@ class Relay(
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful.
connect()
}
@@ -410,7 +414,7 @@ class Relay(
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) {
// println("sendfilter Only if Disconnected ${url} ")
connect()
}
@@ -45,7 +45,7 @@ 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.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
@@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -112,7 +113,9 @@ class MainActivity : AppCompatActivity() {
val accountStateViewModel: AccountStateViewModel = viewModel()
accountStateViewModel.serviceManager = serviceManager
LaunchedEffect(key1 = Unit) { accountStateViewModel.tryLoginExistingAccountAsync() }
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
}
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
@@ -157,7 +160,9 @@ class MainActivity : AppCompatActivity() {
override fun onPause() {
Log.d("Lifetime Event", "MainActivity.onPause")
LanguageTranslatorService.clear()
GlobalScope.launch(Dispatchers.IO) {
LanguageTranslatorService.clear()
}
serviceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
@@ -236,9 +241,9 @@ class MainActivity : AppCompatActivity() {
if (changedNetwork) {
if (isOnMobileData) {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE)
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_MOBILE)
} else {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI)
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_WIFI)
}
}
@@ -341,6 +346,7 @@ fun uriToRoute(uri: String?): String? {
Route.Home.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
import java.net.URL
@@ -57,6 +58,7 @@ class ImageDownloader {
null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
tentatives++
delay(1000)
null
@@ -30,7 +30,8 @@ import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
@@ -55,7 +56,7 @@ object ImageSaver {
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
val client = HttpClient.getHttpClient()
val client = HttpClientManager.getHttpClient()
val request =
Request.Builder()
@@ -102,6 +103,7 @@ object ImageSaver {
}
onSuccess()
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
onError(e)
}
@@ -138,6 +140,7 @@ object ImageSaver {
}
onSuccess()
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
onError(e)
}
@@ -176,6 +179,7 @@ object ImageSaver {
outputStream.use { contentSource.readAll(it.sink()) }
} catch (e: Exception) {
if (e is CancellationException) throw e
contentResolver.delete(uri, null, null)
throw e
}
@@ -36,6 +36,7 @@ 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.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -148,6 +149,7 @@ open class NewMediaModel : ViewModel() {
context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -231,6 +232,7 @@ fun ImageVideoPost(
try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
@@ -279,6 +281,7 @@ fun ImageVideoPost(
modifier = Modifier.fillMaxWidth(),
) {
SettingSwitchItem(
modifier = Modifier.fillMaxWidth().padding(8.dp),
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
title = R.string.add_sensitive_content_label,
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.toNpub
import kotlinx.coroutines.CancellationException
class NewMessageTagger(
var message: String,
@@ -196,6 +197,7 @@ class NewMessageTagger(
) // no way to know when they address ends and dirt begins
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
}
@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CancellationException
@Composable
fun NewPollClosing(pollViewModel: NewPostViewModel) {
@@ -58,6 +59,7 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) {
pollViewModel.closedAt = int
}
} catch (e: Exception) {
if (e is CancellationException) throw e
pollViewModel.isValidClosedAt.value = false
}
}
@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CancellationException
@Composable
fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
@@ -58,6 +59,7 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
pollViewModel.consensusThreshold = int
}
} catch (e: Exception) {
if (e is CancellationException) throw e
pollViewModel.isValidConsensusThreshold.value = false
}
}
@@ -30,6 +30,7 @@ import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -49,8 +50,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -98,7 +97,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
@@ -157,9 +155,9 @@ import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -179,6 +177,7 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -262,7 +261,7 @@ fun NewPostView(
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
contentDescription = stringResource(id = R.string.relay_list_selector),
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
@@ -303,25 +302,38 @@ fun NewPostView(
) { pad ->
Surface(
modifier =
Modifier.padding(
start = Size10dp,
top = pad.calculateTopPadding(),
end = Size10dp,
bottom = pad.calculateBottomPadding(),
)
Modifier
.padding(
start = Size10dp,
top = pad.calculateTopPadding(),
end = Size10dp,
bottom = pad.calculateBottomPadding(),
)
.fillMaxSize(),
) {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(),
) {
Column(
modifier = Modifier.imePadding().weight(1f),
modifier =
Modifier
.imePadding()
.weight(1f),
) {
Row(
modifier = Modifier.fillMaxWidth().weight(1f),
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
) {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
) {
postViewModel.originalNote?.let {
Row(Modifier.heightIn(max = 200.dp)) {
@@ -473,7 +485,8 @@ fun NewPostView(
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier =
Modifier.padding(top = 4.dp)
Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
@@ -510,79 +523,13 @@ fun NewPostView(
}
}
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding =
PaddingValues(
top = 10.dp,
),
modifier = Modifier.heightIn(0.dp, 300.dp),
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex },
) { _, item ->
UserLine(item, accountViewModel) { postViewModel.autocompleteWithUser(item) }
}
}
}
ShowUserSuggestionList(
postViewModel,
accountViewModel,
modifier = Modifier.heightIn(0.dp, 300.dp),
)
Row(
modifier = Modifier.fillMaxWidth().height(50.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
postViewModel.selectImage(it)
}
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringResource(R.string.poll_hashtag)
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
if (postViewModel.wantsPoll) {
postViewModel.wantsProduct = false
}
}
}
AddClassifiedsButton(postViewModel) {
postViewModel.wantsProduct = !postViewModel.wantsProduct
if (postViewModel.wantsProduct) {
postViewModel.wantsPoll = false
}
}
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapraiser) {
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
}
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
AddGeoHash(postViewModel) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
}
BottomRowActions(postViewModel)
}
}
}
@@ -590,6 +537,71 @@ fun NewPostView(
}
}
@Composable
private fun BottomRowActions(postViewModel: NewPostViewModel) {
val scrollState = rememberScrollState()
Row(
modifier =
Modifier
.horizontalScroll(scrollState)
.fillMaxWidth()
.height(50.dp),
verticalAlignment = CenterVertically,
) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
postViewModel.selectImage(it)
}
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringResource(R.string.poll_hashtag)
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
if (postViewModel.wantsPoll) {
postViewModel.wantsProduct = false
}
}
}
AddClassifiedsButton(postViewModel) {
postViewModel.wantsProduct = !postViewModel.wantsProduct
if (postViewModel.wantsProduct) {
postViewModel.wantsPoll = false
}
}
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapraiser) {
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
}
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
AddGeoHash(postViewModel) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
}
}
@Composable
private fun PollField(postViewModel: NewPostViewModel) {
Column(
@@ -622,7 +634,6 @@ private fun PollField(postViewModel: NewPostViewModel) {
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun MessageField(postViewModel: NewPostViewModel) {
val focusRequester = remember { FocusRequester() }
@@ -643,7 +654,8 @@ private fun MessageField(postViewModel: NewPostViewModel) {
capitalization = KeyboardCapitalization.Sentences,
),
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.surface,
@@ -683,21 +695,32 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Box(
Modifier.height(20.dp).width(25.dp),
Modifier
.height(20.dp)
.width(25.dp),
) {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = stringResource(id = R.string.content_warning),
modifier = Modifier.size(18.dp).align(Alignment.BottomStart),
modifier =
Modifier
.size(18.dp)
.align(Alignment.BottomStart),
tint = Color.Red,
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(id = R.string.content_warning),
modifier = Modifier.size(10.dp).align(Alignment.TopEnd),
modifier =
Modifier
.size(10.dp)
.align(Alignment.TopEnd),
tint = Color.Yellow,
)
}
@@ -920,7 +943,10 @@ fun SellProduct(postViewModel: NewPostViewModel) {
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
options = conditionOptions,
onSelect = { postViewModel.condition = conditionTypes[it].first },
modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp),
modifier =
Modifier
.weight(1f)
.padding(end = 5.dp, bottom = 1.dp),
) { currentOption, modifier ->
MyTextField(
value = TextFieldValue(currentOption),
@@ -981,7 +1007,10 @@ fun SellProduct(postViewModel: NewPostViewModel) {
?: "",
options = categoryOptions,
onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) },
modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp),
modifier =
Modifier
.weight(1f)
.padding(end = 5.dp, bottom = 1.dp),
) { currentOption, modifier ->
MyTextField(
value = TextFieldValue(currentOption),
@@ -1046,21 +1075,32 @@ fun FowardZapTo(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Box(
Modifier.height(20.dp).width(25.dp),
Modifier
.height(20.dp)
.width(25.dp),
) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp).align(Alignment.CenterStart),
modifier =
Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange,
)
Icon(
imageVector = Icons.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(13.dp).align(Alignment.CenterEnd),
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange,
)
}
@@ -1160,10 +1200,15 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Box(
Modifier.height(20.dp).width(20.dp),
Modifier
.height(20.dp)
.width(20.dp),
) {
Icon(
imageVector = Icons.Default.LocationOn,
@@ -1304,32 +1349,46 @@ private fun AddZapraiserButton(
onClick = { onClick() },
) {
Box(
Modifier.height(20.dp).width(25.dp),
Modifier
.height(20.dp)
.width(25.dp),
) {
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
modifier =
Modifier
.size(20.dp)
.align(Alignment.TopStart),
tint = MaterialTheme.colorScheme.onBackground,
)
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(13.dp).align(Alignment.BottomEnd),
contentDescription = stringResource(R.string.add_zapraiser),
modifier =
Modifier
.size(13.dp)
.align(Alignment.BottomEnd),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
modifier =
Modifier
.size(20.dp)
.align(Alignment.TopStart),
tint = BitcoinOrange,
)
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(13.dp).align(Alignment.BottomEnd),
contentDescription = stringResource(R.string.cancel_zapraiser),
modifier =
Modifier
.size(13.dp)
.align(Alignment.BottomEnd),
tint = BitcoinOrange,
)
}
@@ -1348,14 +1407,14 @@ fun AddGeoHash(
if (!postViewModel.wantsToAddGeoHash) {
Icon(
imageVector = Icons.Default.LocationOff,
null,
contentDescription = stringResource(id = R.string.add_location),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.LocationOn,
null,
contentDescription = stringResource(id = R.string.remove_location),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
@@ -1374,14 +1433,14 @@ private fun AddLnInvoiceButton(
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
null,
contentDescription = stringResource(id = R.string.add_bitcoin_invoice),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
null,
contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange,
)
@@ -1398,32 +1457,46 @@ private fun ForwardZapTo(
onClick = { onClick() },
) {
Box(
Modifier.height(20.dp).width(25.dp),
Modifier
.height(20.dp)
.width(25.dp),
) {
if (!postViewModel.wantsForwardZapTo) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp).align(Alignment.CenterStart),
contentDescription = stringResource(R.string.add_zap_split),
modifier =
Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = MaterialTheme.colorScheme.onBackground,
)
Icon(
imageVector = Icons.Default.ArrowForwardIos,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(13.dp).align(Alignment.CenterEnd),
contentDescription = null,
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp).align(Alignment.CenterStart),
contentDescription = stringResource(id = R.string.cancel_zap_split),
modifier =
Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange,
)
Icon(
imageVector = Icons.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(13.dp).align(Alignment.CenterEnd),
contentDescription = null,
modifier =
Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange,
)
}
@@ -1449,7 +1522,7 @@ private fun AddClassifiedsButton(
} else {
Icon(
imageVector = Icons.Default.Sell,
contentDescription = stringResource(id = R.string.classifieds),
contentDescription = stringResource(id = R.string.cancel_classifieds),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange,
)
@@ -1466,32 +1539,46 @@ private fun MarkAsSensitive(
onClick = { onClick() },
) {
Box(
Modifier.height(20.dp).width(23.dp),
Modifier
.height(20.dp)
.width(23.dp),
) {
if (!postViewModel.wantsToMarkAsSensitive) {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.content_warning),
modifier = Modifier.size(18.dp).align(Alignment.BottomStart),
contentDescription = stringResource(R.string.add_content_warning),
modifier =
Modifier
.size(18.dp)
.align(Alignment.BottomStart),
tint = MaterialTheme.colorScheme.onBackground,
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(R.string.content_warning),
modifier = Modifier.size(10.dp).align(Alignment.TopEnd),
contentDescription = null,
modifier =
Modifier
.size(10.dp)
.align(Alignment.TopEnd),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = stringResource(id = R.string.content_warning),
modifier = Modifier.size(18.dp).align(Alignment.BottomStart),
contentDescription = stringResource(id = R.string.remove_content_warning),
modifier =
Modifier
.size(18.dp)
.align(Alignment.BottomStart),
tint = Color.Red,
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(id = R.string.content_warning),
modifier = Modifier.size(10.dp).align(Alignment.TopEnd),
contentDescription = null,
modifier =
Modifier
.size(10.dp)
.align(Alignment.TopEnd),
tint = Color.Yellow,
)
}
@@ -1600,7 +1687,8 @@ fun ImageVideoDescription(
Column(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = QuoteBorder)
.border(
@@ -1610,11 +1698,17 @@ fun ImageVideoDescription(
),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(30.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(30.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Text(
text =
@@ -1632,13 +1726,17 @@ fun ImageVideoDescription(
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier =
Modifier.padding(start = 10.dp)
Modifier
.padding(start = 10.dp)
.weight(1.0f)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
IconButton(
modifier = Modifier.size(30.dp).padding(end = 5.dp),
modifier =
Modifier
.size(30.dp)
.padding(end = 5.dp),
onClick = onCancel,
) {
CancelIcon()
@@ -1650,7 +1748,8 @@ fun ImageVideoDescription(
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) {
@@ -1660,7 +1759,8 @@ fun ImageVideoDescription(
contentDescription = uri.toString(),
contentScale = ContentScale.FillWidth,
modifier =
Modifier.padding(top = 4.dp)
Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
@@ -1674,6 +1774,7 @@ fun ImageVideoDescription(
try {
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Unable to load thumbnail")
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
@@ -1685,7 +1786,10 @@ fun ImageVideoDescription(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.FillWidth,
modifier = Modifier.padding(top = 4.dp).fillMaxWidth(),
modifier =
Modifier
.padding(top = 4.dp)
.fillMaxWidth(),
)
}
} else {
@@ -1707,7 +1811,10 @@ fun ImageVideoDescription(
?: fileServers[0].server.name,
options = fileServerOptions,
onSelect = { selectedServer = fileServers[it] },
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
modifier =
Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.weight(1f),
)
}
@@ -1716,6 +1823,10 @@ fun ImageVideoDescription(
modifier = Modifier.fillMaxWidth(),
) {
SettingSwitchItem(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
checked = sensitiveContent,
onCheckedChange = { sensitiveContent = it },
title = R.string.add_sensitive_content_label,
@@ -1726,12 +1837,16 @@ fun ImageVideoDescription(
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
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)),
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = message,
onValueChange = { message = it },
placeholder = {
@@ -1748,7 +1863,10 @@ fun ImageVideoDescription(
}
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = { onAdd(message, selectedServer, sensitiveContent) },
shape = QuoteBorder,
colors =
@@ -1764,7 +1882,10 @@ fun ImageVideoDescription(
@Composable
fun SettingSwitchItem(
modifier: Modifier = Modifier,
modifier: Modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
title: Int,
@@ -1774,8 +1895,6 @@ fun SettingSwitchItem(
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.toggleable(
value = checked,
enabled = enabled,
@@ -1786,7 +1905,7 @@ fun SettingSwitchItem(
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(3.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringResource(id = title),
@@ -63,6 +63,7 @@ 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.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
@@ -70,7 +71,6 @@ 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,
@@ -88,6 +88,7 @@ open class NewPostViewModel() : ViewModel() {
var pTags by mutableStateOf<List<User>?>(null)
var eTags by mutableStateOf<List<Note>?>(null)
var imetaTags = mutableStateListOf<Array<String>>()
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
var nip95attachments by
@@ -264,45 +265,47 @@ open class NewPostViewModel() : ViewModel() {
}
val urls = findURLs(tagger.message)
val usedAttachments = nip94attachments.filter { it.urls().intersect(urls).isNotEmpty() }
usedAttachments.forEach { account?.sendHeader(it, relayList, {}) }
val usedAttachments = nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
// Doesn't send as nip94 yet because we don't know if it makes sense.
// usedAttachments.forEach { account?.sendHeader(it, relayList, {}) }
if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(
tagger.message,
originalNote?.address()!!,
tagger.eTags,
tagger.pTags,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
geoHash,
message = tagger.message,
toChannel = originalNote?.address()!!,
replyTo = tagger.eTags,
mentions = tagger.pTags,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
)
} else {
account?.sendChannelMessage(
tagger.message,
tagger.channelHex!!,
tagger.eTags,
tagger.pTags,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
geoHash,
message = tagger.message,
toChannel = tagger.channelHex!!,
replyTo = tagger.eTags,
mentions = tagger.pTags,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
)
}
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(
tagger.message,
originalNote!!.author!!,
originalNote!!,
tagger.pTags,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
geoHash,
message = tagger.message,
toUser = originalNote!!.author!!,
replyingTo = originalNote!!,
mentions = tagger.pTags,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
)
} else if (originalNote?.event is ChatMessageEvent) {
val receivers =
@@ -323,6 +326,7 @@ open class NewPostViewModel() : ViewModel() {
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
@@ -336,6 +340,7 @@ open class NewPostViewModel() : ViewModel() {
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
)
} else {
account?.sendPrivateMessage(
@@ -347,6 +352,7 @@ open class NewPostViewModel() : ViewModel() {
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
)
}
} else {
@@ -460,22 +466,14 @@ open class NewPostViewModel() : ViewModel() {
onProgress = {},
)
if (!isPrivate) {
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
)
} else {
noNIP94(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
)
}
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
@@ -506,6 +504,7 @@ open class NewPostViewModel() : ViewModel() {
urlPreview = null
isUploadingImage = false
pTags = null
imetaTags.clear()
wantsDirectMessage = false
@@ -701,21 +700,6 @@ open class NewPostViewModel() : ViewModel() {
contentToAddUrl == null
}
fun includePollHashtagInMessage(
include: Boolean,
hashtag: String,
) {
if (include) {
updateMessage(TextFieldValue(message.text + " $hashtag"))
} else {
updateMessage(
TextFieldValue(
message.text.replace(" $hashtag", "").replace(hashtag, ""),
),
)
}
}
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
@@ -749,25 +733,13 @@ open class NewPostViewModel() : ViewModel() {
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) {
event,
->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false
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,
),
message.text + "\n" + imageUrl,
)
urlPreview = findUrlInMessage()
}
@@ -779,86 +751,6 @@ open class NewPostViewModel() : ViewModel() {
)
}
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?,
@@ -944,6 +836,7 @@ open class NewPostViewModel() : ViewModel() {
valueMinimum = int
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
} else {
valueMinimum = null
@@ -962,6 +855,7 @@ open class NewPostViewModel() : ViewModel() {
valueMaximum = int
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
} else {
valueMaximum = null
@@ -37,6 +37,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class NewUserMetadataViewModel : ViewModel() {
private lateinit var account: Account
@@ -197,6 +198,7 @@ class NewUserMetadataViewModel : ViewModel() {
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
@@ -33,6 +33,7 @@ import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
data class RangesChanges(val original: TextRange, val modified: TextRange)
@@ -55,11 +56,9 @@ fun buildAnnotatedStringWithUrlHighlighting(
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
append(
text
.split('\n')
.map { paragraph: String ->
.split('\n').joinToString("\n") { paragraph: String ->
paragraph
.split(' ')
.map { word: String ->
.split(' ').joinToString(" ") { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
@@ -113,15 +112,14 @@ fun buildAnnotatedStringWithUrlHighlighting(
word
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// if it can't parse the key, don't try to change.
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
}
.joinToString(" ")
}
.joinToString("\n"),
},
)
substitutions.forEach {
@@ -141,9 +139,7 @@ fun buildAnnotatedStringWithUrlHighlighting(
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val inInsideRange =
substitutions
.filter { offset > it.original.start && offset < it.original.end }
.firstOrNull()
substitutions.firstOrNull { offset > it.original.start && offset < it.original.end }
if (inInsideRange != null) {
val percentInRange =
@@ -163,9 +159,7 @@ fun buildAnnotatedStringWithUrlHighlighting(
override fun transformedToOriginal(offset: Int): Int {
val inInsideRange =
substitutions
.filter { offset > it.modified.start && offset < it.modified.end }
.firstOrNull()
substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end }
if (inInsideRange != null) {
val percentInRange =
@@ -123,7 +123,7 @@ fun ChannelFabColumn(
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringResource(R.string.messages_create_public_chat),
contentDescription = stringResource(R.string.messages_create_public_private_chat_desription),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
@@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -72,7 +73,7 @@ fun NewCommunityNoteButton(
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
contentDescription = stringResource(id = R.string.new_community_note),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -135,7 +136,7 @@ fun NewImageButton(
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
contentDescription = stringResource(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
@@ -153,7 +154,10 @@ private fun ShowProgress(postViewModel: NewMediaModel) {
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
)
.value,
modifier = Size55Modifier.clip(CircleShape).background(MaterialTheme.colorScheme.background),
modifier =
Size55Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background),
strokeWidth = 5.dp,
)
postViewModel.uploadingDescription.value?.let {
@@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostView
@@ -58,7 +59,7 @@ fun NewNoteButton(
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
contentDescription = stringResource(R.string.new_post),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -238,6 +239,7 @@ fun CashuPreview(
startActivity(context, intent, null)
} catch (e: Exception) {
if (e is CancellationException) throw e
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
}
},
@@ -354,6 +356,7 @@ fun CashuPreviewNew(
startActivity(context, intent, null)
} catch (e: Exception) {
if (e is CancellationException) throw e
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
}
},
@@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import kotlinx.coroutines.CancellationException
@Composable
fun ClickableEmail(email: String) {
@@ -58,6 +59,7 @@ fun Context.sendMail(
} catch (e: ActivityNotFoundException) {
// TODO: Handle case where no email app is available
} catch (t: Throwable) {
if (t is CancellationException) throw t
// TODO: Handle potential other type of exceptions
}
}
@@ -24,7 +24,6 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalContentColor
@@ -280,12 +279,10 @@ private fun RenderUserAsClickableText(
derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
}
val addedCharts = remember(nip19) { "${nip19.additionalChars}" }
userDisplayName?.let {
CreateClickableTextWithEmoji(
clickablePart = it,
suffix = addedCharts,
suffix = nip19.additionalChars,
maxLines = 1,
route = route,
nav = nav,
@@ -300,27 +297,24 @@ fun CreateClickableText(
suffix: String?,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
fontWeight: FontWeight? = null,
route: String,
nav: (String) -> Unit,
) {
val currentStyle = LocalTextStyle.current
val primaryColor = MaterialTheme.colorScheme.primary
val onBackgroundColor = MaterialTheme.colorScheme.onBackground
val clickablePartStyle =
remember(primaryColor, overrideColor) {
currentStyle
.copy(color = overrideColor ?: primaryColor, fontWeight = fontWeight)
.toSpanStyle()
}
SpanStyle(
color = overrideColor ?: primaryColor,
fontWeight = fontWeight,
)
val nonClickablePartStyle =
remember(onBackgroundColor, overrideColor) {
currentStyle
.copy(color = overrideColor ?: onBackgroundColor, fontWeight = fontWeight)
.toSpanStyle()
}
SpanStyle(
color = overrideColor ?: onBackgroundColor,
fontWeight = fontWeight,
)
val text =
remember(clickablePartStyle, nonClickablePartStyle, clickablePart, suffix) {
@@ -339,6 +333,41 @@ fun CreateClickableText(
)
}
@Composable
fun ClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit,
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator =
Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}
Text(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
},
)
}
@Composable
fun CreateTextWithEmoji(
text: String,
@@ -388,7 +417,7 @@ fun CreateTextWithEmoji(
.merge(
TextStyle(
color = textColor,
textAlign = textAlign,
textAlign = TextAlign.Unspecified,
fontWeight = fontWeight,
fontSize = fontSize,
),
@@ -446,7 +475,7 @@ fun CreateTextWithEmoji(
.merge(
TextStyle(
color = textColor,
textAlign = textAlign,
textAlign = TextAlign.Unspecified,
fontWeight = fontWeight,
fontSize = fontSize,
),
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -44,6 +43,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ExpandableTextParser
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -51,9 +51,6 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.events.ImmutableListOfLists
const val SHORT_TEXT_LENGTH = 350
const val SHORTEN_AFTER_LINES = 10
@Composable
fun ExpandableRichTextViewer(
content: String,
@@ -66,30 +63,7 @@ fun ExpandableRichTextViewer(
) {
var showFullText by remember { mutableStateOf(false) }
val whereToCut =
remember(content) {
// Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters
val firstSpaceAfterCut =
content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut =
content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
// or after SHORTEN_AFTER_LINES lines
val numberOfLines = content.count { it == '\n' }
var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
if (numberOfLines > SHORTEN_AFTER_LINES) {
val shortContent = content.lines().take(SHORTEN_AFTER_LINES)
charactersInLines = 0
for (line in shortContent) {
// +1 because new line character is omitted from .lines
charactersInLines += (line.length + 1)
}
}
minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines)
}
val whereToCut = remember(content) { ExpandableTextParser.computeWhereToCutIfPostIsTooLong(content) }
val text by
remember(content) {
@@ -103,17 +77,15 @@ fun ExpandableRichTextViewer(
}
Box {
Crossfade(text, label = "ExpandableRichTextViewer") {
RichTextViewer(
it,
canPreview,
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
accountViewModel,
nav,
)
}
RichTextViewer(
text,
canPreview,
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
accountViewModel,
nav,
)
if (content.length > whereToCut && !showFullText) {
Row(
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.text.NumberFormat
@@ -79,6 +80,7 @@ fun LoadValueFromInvoice(
try {
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
null
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.coroutines.CancellationException
class MarkdownParser {
private fun getDisplayNameAndNIP19FromTag(
@@ -39,6 +40,7 @@ class MarkdownParser {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Tag Parser", "Couldn't link tag $tag", e)
Pair(null, null)
}
@@ -161,6 +163,7 @@ class MarkdownParser {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
@@ -33,6 +33,7 @@ import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import kotlinx.coroutines.CancellationException
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
@@ -115,6 +116,7 @@ class MediaCompressor {
}
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
onReady(uri, contentType, null)
}
@@ -28,11 +28,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
@@ -49,7 +48,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -62,8 +60,10 @@ import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
@@ -100,9 +100,9 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -174,7 +174,73 @@ fun RichTextViewer(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Preview()
@Composable
fun RenderRegularPreview() {
val backgroundColor = MaterialTheme.colorScheme.background
val backgroundColorState =
remember {
mutableStateOf(backgroundColor)
}
val nav: (String) -> Unit = {}
Column(modifier = Modifier.padding(10.dp)) {
RenderRegular(
"nostr:npub1e0z776cpe0gllgktjk54fuzv8pdfxmq6smsmh8xd7t8s7n474n9smk0txy but i'm Monthly funding" +
" 7 other humans vitor@vitorpamplona.com at the moment so spread #test a bit thin, but won't always be the case.",
EmptyTagList,
) { word, state ->
when (word) {
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
// is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
// is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> {
CreateClickableText(
word.segmentText.substring(0, 10),
"",
1,
route = "",
nav = nav,
)
}
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
}
}
RenderRegular(
"#Amethyst v0.84.1: ncryptsec support (NIP-49)",
EmptyTagList,
) { word, state ->
when (word) {
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
// is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
// is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
}
}
}
}
@Composable
private fun RenderRegular(
content: String,
@@ -184,75 +250,65 @@ private fun RenderRegular(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val state by remember(content) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) }
RenderRegular(content, tags) { word, state ->
if (canPreview) {
RenderWordWithPreview(
word,
state,
backgroundColor,
accountViewModel,
nav,
)
} else {
RenderWordWithoutPreview(
word,
state,
backgroundColor,
accountViewModel,
nav,
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
tags: ImmutableListOfLists<String>,
wordRenderer: @Composable (Segment, RichTextViewerState) -> Unit,
) {
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) }
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
val currentTextStyle = LocalTextStyle.current
val currentTextColor = LocalContentColor.current
val textStyle =
remember(currentTextStyle, currentTextColor) {
remember(currentTextStyle) {
currentTextStyle.copy(
lineHeight = 1.4.em,
color = currentTextStyle.color.takeOrElse { currentTextColor },
)
}
val spaceWidth = measureSpaceWidth(textStyle)
Column {
if (canPreview) {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
val direction =
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
CompositionLocalProvider(
LocalLayoutDirection provides
if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
) {
paragraph.words.forEach { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav,
)
}
}
}
}
} else {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
val direction =
if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
) {
paragraph.words.forEach { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav,
)
}
},
LocalTextStyle provides textStyle,
) {
FlowRow(
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
) {
paragraph.words.forEach { word ->
wordRenderer(word, state)
}
}
}
@@ -281,7 +337,6 @@ private fun RenderWordWithoutPreview(
word: Segment,
state: RichTextViewerState,
backgroundColor: MutableState<Color>,
style: TextStyle,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -291,10 +346,10 @@ private fun RenderWordWithoutPreview(
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
// Don't offer to pay invoices
is InvoiceSegment -> NormalWord(word.segmentText, style)
is InvoiceSegment -> Text(word.segmentText)
// Don't offer to withdraw
is WithdrawSegment -> NormalWord(word.segmentText, style)
is CashuSegment -> NormalWord(word.segmentText, style)
is WithdrawSegment -> Text(word.segmentText)
is CashuSegment -> Text(word.segmentText)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
@@ -302,7 +357,7 @@ private fun RenderWordWithoutPreview(
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> NormalWord(word.segmentText, style)
is RegularTextSegment -> Text(word.segmentText)
}
}
@@ -311,7 +366,6 @@ private fun RenderWordWithPreview(
word: Segment,
state: RichTextViewerState,
backgroundColor: MutableState<Color>,
style: TextStyle,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -329,7 +383,7 @@ private fun RenderWordWithPreview(
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> NormalWord(word.segmentText, style)
is RegularTextSegment -> Text(word.segmentText)
}
}
@@ -347,23 +401,7 @@ private fun ZoomableContentView(
}
@Composable
private fun NormalWord(
word: String,
style: TextStyle,
) {
BasicText(
text = word,
style = style,
)
}
@Composable
private fun NoProtocolUrlRenderer(word: SchemelessUrlSegment) {
RenderUrl(word)
}
@Composable
private fun RenderUrl(segment: SchemelessUrlSegment) {
private fun NoProtocolUrlRenderer(segment: SchemelessUrlSegment) {
ClickableUrl(segment.url, "https://${segment.url}")
segment.extras?.let { it1 -> Text(it1) }
}
@@ -416,8 +454,8 @@ private fun RenderContentAsMarkdown(
onMediaCompose = { title, destination ->
ZoomableContentView(
content =
remember(destination) {
RichTextParser().parseMediaUrl(destination) ?: ZoomableUrlImage(url = destination)
remember(destination, tags) {
RichTextParser().parseMediaUrl(destination, tags ?: EmptyTagList) ?: ZoomableUrlImage(url = destination)
},
roundedCorner = true,
accountViewModel = accountViewModel,
@@ -580,7 +618,7 @@ fun BechLink(
)
}
} else if (loadedLink?.nip19 != null) {
Row { ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav) }
ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav)
} else {
val text =
remember(word) {
@@ -606,7 +644,7 @@ private fun DisplayFullNote(
NoteCompose(
baseNote = it,
accountViewModel = accountViewModel,
modifier = MaterialTheme.colorScheme.replyModifier,
modifier = MaterialTheme.colorScheme.innerPostModifier,
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav,
@@ -623,14 +661,6 @@ private fun DisplayFullNote(
@Composable
fun HashTag(
word: HashTagSegment,
nav: (String) -> Unit,
) {
RenderHashtag(word, nav)
}
@Composable
private fun RenderHashtag(
segment: HashTagSegment,
nav: (String) -> Unit,
) {
@@ -657,7 +687,7 @@ private fun RenderHashtag(
}
}
segment.extras?.ifBlank { "" }?.let { withStyle(regularText) { append(it) } }
segment.extras?.let { withStyle(regularText) { append(it) } }
}
}
@@ -117,7 +117,7 @@ fun RobohashFallbackAsyncImage(
Image(
painter = base64Painter,
contentDescription = null,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
@@ -74,8 +74,17 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
val hasSensitiveContent = remember(event) { event.isSensitive() ?: false }
val hasSensitiveContent = remember(event) { event.isSensitive() }
SensitivityWarning(hasSensitiveContent, accountViewModel, content)
}
@Composable
fun SensitivityWarning(
hasSensitiveContent: Boolean,
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
if (hasSensitiveContent) {
SensitivityWarning(accountViewModel, content)
} else {
@@ -20,18 +20,26 @@
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
@@ -41,16 +49,59 @@ import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
@Composable
private fun CopyToClipboard(
popupExpanded: MutableState<Boolean>,
content: String,
onDismiss: () -> Unit,
) {
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
DropdownMenuItem(
text = { Text(stringResource(R.string.copy_url_to_clipboard)) },
onClick = {
clipboardManager.setText(AnnotatedString(content))
onDismiss()
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem,
) {
val uri = LocalUriHandler.current
val popupExpanded =
remember {
mutableStateOf(false)
}
if (popupExpanded.value) {
CopyToClipboard(
popupExpanded = popupExpanded,
content = url,
) {
popupExpanded.value = false
}
}
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier.clickable { runCatching { uri.openUri(url) } },
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = {
runCatching { uri.openUri(url) }
},
onLongClick = {
popupExpanded.value = true
},
),
) {
AsyncImage(
model = previewInfo.imageUrlFullPath,
@@ -106,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
@@ -356,6 +357,7 @@ fun GetMediaItem(
null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
},
)
@@ -131,6 +131,7 @@ import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -161,6 +162,7 @@ class ZoomableUrlImage(
val blurhash: String? = null,
dim: String? = null,
uri: String? = null,
val contentWarning: String? = null,
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
@@ -173,6 +175,7 @@ class ZoomableUrlVideo(
val artworkUri: String? = null,
val authorName: String? = null,
val blurhash: String? = null,
val contentWarning: String? = null,
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
@@ -265,20 +268,24 @@ fun ZoomableContentView(
when (content) {
is ZoomableUrlImage ->
UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel)
SensitivityWarning(content.contentWarning != null, accountViewModel) {
UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel)
}
is ZoomableUrlVideo ->
VideoView(
videoUri = content.url,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
dimensions = content.dim,
blurhash = content.blurhash,
roundedCorner = roundedCorner,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
)
SensitivityWarning(content.contentWarning != null, accountViewModel) {
VideoView(
videoUri = content.url,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
dimensions = content.dim,
blurhash = content.blurhash,
roundedCorner = roundedCorner,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
)
}
is ZoomableLocalImage ->
LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel)
is ZoomableLocalVideo ->
@@ -387,13 +394,13 @@ private fun UrlImageView(
val myModifier =
remember {
mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight)
/* Is this necessary? It makes images bleed into other pages
/* Is this necessary? It makes images bleed into other pages
.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, false)
} ?: this
}
*/
*/
}
val contentScale =
@@ -644,6 +651,7 @@ fun aspectRatio(dim: String?): Float? {
width / height
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import kotlinx.coroutines.CancellationException
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
@@ -34,7 +36,15 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
}
override fun feed(): List<User> {
return account.flowHiddenUsers.value.hiddenUsers.map { LocalCache.getOrCreateUser(it) }
return account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull {
try {
LocalCache.getOrCreateUser(it)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("HiddenAccountsFeedFilter", "Failed to parse key $it")
null
}
}
}
}
@@ -46,15 +46,13 @@ fun DisplayFollowingHashtagsInPost(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteEvent = remember { baseNote.event } ?: return
val userFollowState by accountViewModel.userFollows.observeAsState()
var firstTag by remember { mutableStateOf<String?>(null) }
var firstTag by remember(baseNote) { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = userFollowState) {
launch(Dispatchers.Default) {
val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet()
val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags)
val newFirstTag = baseNote.event?.firstIsTaggedHashes(followingTags)
if (firstTag != newFirstTag) {
launch(Dispatchers.Main) { firstTag = newFirstTag }
@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -166,6 +167,7 @@ fun DisplayAccount(
decodePublicKey(acc.npub).toHexKey(),
)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
@@ -49,6 +49,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
@@ -180,7 +181,7 @@ private fun NotifiableIcon(
Box(route.notifSize) {
Icon(
painter = painterResource(id = route.icon),
contentDescription = null,
contentDescription = stringResource(route.contentDescriptor),
modifier = route.iconSize,
tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified,
)
@@ -216,7 +217,12 @@ private fun NotificationDotIcon(modifier: Modifier) {
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
modifier = remember { Modifier.wrapContentHeight().align(Alignment.TopEnd) },
modifier =
remember {
Modifier
.wrapContentHeight()
.align(Alignment.TopEnd)
},
)
}
}
@@ -39,6 +39,7 @@ import androidx.core.util.Consumer
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
@@ -74,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.URLDecoder
@Composable
fun AppNavigation(
@@ -288,9 +290,13 @@ fun AppNavigation(
route.route,
route.arguments,
content = {
val decodedMessage =
it.arguments?.getString("message")?.let {
URLDecoder.decode(it, "utf-8")
}
ChatroomScreen(
roomId = it.arguments?.getString("id"),
draftMessage = it.arguments?.getString("message"),
draftMessage = decodedMessage,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -354,31 +360,60 @@ fun AppNavigation(
}
val activity = LocalContext.current.getActivity()
var actionableNextPage by remember {
mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null }))
var currentIntentNextPage by remember {
mutableStateOf(activity.intent?.data?.toString()?.ifBlank { null })
}
actionableNextPage?.let {
LaunchedEffect(it) {
navController.navigate(it) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
currentIntentNextPage?.let { intentNextPage ->
var actionableNextPage by remember {
mutableStateOf(uriToRoute(intentNextPage))
}
LaunchedEffect(intentNextPage) {
if (actionableNextPage != null) {
actionableNextPage?.let {
navController.navigate(it) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
actionableNextPage = null
}
} else {
accountViewModel.toast(
R.string.invalid_nip19_uri,
R.string.invalid_nip19_uri_description,
intentNextPage,
)
}
currentIntentNextPage = null
}
actionableNextPage = null
}
DisposableEffect(activity) {
val consumer =
Consumer<Intent> { intent ->
val uri = intent?.data?.toString()
val newPage = uriToRoute(uri)
if (!uri.isNullOrBlank()) {
val newPage = uriToRoute(uri)
newPage?.let { route ->
val currentRoute = getRouteWithArguments(navController)
if (!isSameRoute(currentRoute, route)) {
navController.navigate(route) {
popUpTo(Route.Home.route)
launchSingleTop = true
if (newPage != null) {
val currentRoute = getRouteWithArguments(navController)
if (!isSameRoute(currentRoute, newPage)) {
navController.navigate(newPage) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
} else {
scope.launch {
delay(1000)
accountViewModel.toast(
R.string.invalid_nip19_uri,
R.string.invalid_nip19_uri_description,
uri,
)
}
}
}
@@ -34,7 +34,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -543,23 +542,17 @@ private fun LoggedInUserPictureDrawer(
) {
val profilePicture by
accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState()
val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex }
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
IconButton(
onClick = onClick,
) {
RobohashFallbackAsyncImage(
robot = pubkeyHex,
robot = accountViewModel.userProfile().pubkeyHex,
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
contentDescription = stringResource(id = R.string.your_profile_image),
modifier = HeaderPictureModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = automaticallyShowProfilePicture,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
)
}
}
@@ -75,7 +75,8 @@ fun routeToMessage(
val withKey = ChatroomKey(persistentSetOf(user))
accountViewModel.account.userProfile().createChatroom(withKey)
return if (draftMessage != null) {
"Room/${withKey.hashCode()}?message=$draftMessage"
val encodedMessage = URLEncoder.encode(draftMessage, "utf-8")
"Room/${withKey.hashCode()}?message=$encodedMessage"
} else {
"Room/${withKey.hashCode()}"
}
@@ -60,6 +60,7 @@ sealed class Route(
val icon: Int,
val notifSize: Modifier = Modifier.size(Size23dp),
val iconSize: Modifier = Modifier.size(Size20dp),
val contentDescriptor: Int = R.string.route,
val hasNewItems: (Account, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _ ->
false
},
@@ -80,6 +81,7 @@ sealed class Route(
},
)
.toImmutableList(),
contentDescriptor = R.string.route_home,
hasNewItems = { accountViewModel, newNotes ->
HomeLatestItem.hasNewItems(accountViewModel, newNotes)
},
@@ -89,27 +91,31 @@ sealed class Route(
Route(
route = "Global",
icon = R.drawable.ic_globe,
contentDescriptor = R.string.route_global,
)
object Search :
Route(
route = "Search",
icon = R.drawable.ic_search,
contentDescriptor = R.string.route_search,
)
object Video :
Route(
route = "Video",
icon = R.drawable.ic_video,
contentDescriptor = R.string.route_video,
)
object Discover :
Route(
route = "Discover",
icon = R.drawable.ic_sensors,
hasNewItems = { accountViewModel, newNotes ->
DiscoverLatestItem.hasNewItems(accountViewModel, newNotes)
},
// hasNewItems = { accountViewModel, newNotes ->
// DiscoverLatestItem.hasNewItems(accountViewModel, newNotes)
// },
contentDescriptor = R.string.route_discover,
)
object Notification :
@@ -119,6 +125,7 @@ sealed class Route(
hasNewItems = { accountViewModel, newNotes ->
NotificationLatestItem.hasNewItems(accountViewModel, newNotes)
},
contentDescriptor = R.string.route_notifications,
)
object Message :
@@ -128,18 +135,21 @@ sealed class Route(
hasNewItems = { accountViewModel, newNotes ->
MessagesLatestItem.hasNewItems(accountViewModel, newNotes)
},
contentDescriptor = R.string.route_messages,
)
object BlockedUsers :
Route(
route = "BlockedUsers",
icon = R.drawable.ic_security,
contentDescriptor = R.string.route_security_filters,
)
object Bookmarks :
Route(
route = "Bookmarks",
icon = R.drawable.ic_bookmarks,
contentDescriptor = R.string.route_home,
)
object Profile :
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
@@ -162,7 +163,7 @@ fun BadgeCompose(
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
@@ -46,6 +46,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -78,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier
import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
@@ -491,8 +493,14 @@ private fun RenderReply(
onWantsToReply: (Note) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val replyTo by remember { derivedStateOf { note.replyTo?.lastOrNull() } }
replyTo?.let { note ->
val replyTo =
produceState(initialValue = note.replyTo?.lastOrNull()) {
accountViewModel.unwrapIfNeeded(value) {
value = it
}
}
replyTo.value?.let { note ->
ChatroomMessageCompose(
note,
null,
@@ -619,19 +627,18 @@ private fun RenderRegularTextNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val modifier = remember { Modifier.padding(top = 5.dp) }
LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent ->
if (eventContent != null) {
SensitivityWarning(
note = note,
accountViewModel = accountViewModel,
) {
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = eventContent!!,
content = eventContent,
canPreview = canPreview,
modifier = modifier,
modifier = HalfTopPadding,
tags = tags,
backgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
@@ -642,8 +649,8 @@ private fun RenderRegularTextNote(
TranslatableRichTextViewer(
content = stringResource(id = R.string.could_not_decrypt_the_message),
canPreview = true,
modifier = modifier,
tags = tags,
modifier = HalfTopPadding,
tags = EmptyTagList,
backgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
@@ -152,7 +152,7 @@ fun LikeIcon(
) {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
contentDescription = stringResource(id = R.string.like_description),
modifier = iconSizeModifier,
tint = grayTint,
)
@@ -165,7 +165,7 @@ fun RepostedIcon(
) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
contentDescription = stringResource(id = R.string.boost_or_quote_description),
modifier = modifier,
tint = tint,
)
@@ -198,10 +198,11 @@ fun ZappedIcon(modifier: Modifier) {
fun ZapIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
contentDescriptor: Int = R.string.zap_description,
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
contentDescription = stringResource(contentDescriptor),
tint = tint,
modifier = modifier,
)
@@ -244,20 +245,26 @@ fun OpenInNewIcon(
}
@Composable
fun ExpandLessIcon(modifier: Modifier) {
fun ExpandLessIcon(
modifier: Modifier,
contentDescriptor: Int,
) {
Icon(
imageVector = Icons.Default.ExpandLess,
null,
contentDescription = stringResource(id = contentDescriptor),
modifier = modifier,
tint = MaterialTheme.colorScheme.subtleButton,
)
}
@Composable
fun ExpandMoreIcon(modifier: Modifier) {
fun ExpandMoreIcon(
modifier: Modifier,
contentDescriptor: Int,
) {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
contentDescription = stringResource(id = contentDescriptor),
modifier = modifier,
tint = MaterialTheme.colorScheme.subtleButton,
)
@@ -270,7 +277,7 @@ fun CommentIcon(
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
contentDescription = null,
contentDescription = stringResource(id = R.string.reply_description),
modifier = iconSizeModifier,
tint = tint,
)
@@ -293,7 +300,7 @@ fun ViewCountIcon(
fun PollIcon() {
Icon(
painter = painterResource(R.drawable.ic_poll),
null,
contentDescription = stringResource(id = R.string.poll),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
@@ -303,7 +310,7 @@ fun PollIcon() {
fun RegularPostIcon() {
Icon(
painter = painterResource(R.drawable.ic_lists),
null,
contentDescription = stringResource(id = R.string.disable_poll),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
@@ -435,10 +442,10 @@ fun LinkIcon(
}
@Composable
fun VerticalDotsIcon() {
fun VerticalDotsIcon(contentDescriptor: Int? = null) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
contentDescription = contentDescriptor?.let { stringResource(id = it) },
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
@@ -542,11 +542,6 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
author: User,
accountViewModel: AccountViewModel,
) {
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
WatchUserMetadata(author) { baseUserPicture ->
// Crossfade(targetState = baseUserPicture) { userPicture ->
RobohashFallbackAsyncImage(
@@ -555,7 +550,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
contentDescription = stringResource(id = R.string.profile_image),
modifier = MaterialTheme.colorScheme.profile35dpModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = automaticallyShowProfilePicture,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
)
// }
}
@@ -795,7 +795,11 @@ fun ShortCommunityHeader(
}
Column(
modifier = Modifier.padding(start = 10.dp).height(Size35dp).weight(1f),
modifier =
Modifier
.padding(start = 10.dp)
.height(Size35dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = CenterVertically) {
@@ -808,7 +812,10 @@ fun ShortCommunityHeader(
}
Row(
modifier = Modifier.height(Size35dp).padding(start = 5.dp),
modifier =
Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = CenterVertically,
) {
ShortCommunityActionOptions(baseNote, accountViewModel, nav)
@@ -1429,7 +1436,8 @@ fun RenderAppDefinition(
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.height(125.dp)
.combinedClickable(
onClick = {},
@@ -1449,12 +1457,19 @@ fun RenderAppDefinition(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(id = R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(125.dp),
modifier =
Modifier
.fillMaxWidth()
.height(125.dp),
)
}
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.padding(top = 75.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -1469,11 +1484,12 @@ fun RenderAppDefinition(
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier =
Modifier.border(
3.dp,
MaterialTheme.colorScheme.background,
CircleShape,
)
Modifier
.border(
3.dp,
MaterialTheme.colorScheme.background,
CircleShape,
)
.clip(shape = CircleShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
@@ -1496,7 +1512,10 @@ fun RenderAppDefinition(
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier.height(Size35dp).padding(bottom = 3.dp),
modifier =
Modifier
.height(Size35dp)
.padding(bottom = 3.dp),
) {}
}
@@ -1681,7 +1700,10 @@ fun DisplayRelaySet(
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center,
)
@@ -1690,7 +1712,10 @@ fun DisplayRelaySet(
text = it,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center,
color = Color.Gray,
)
@@ -1705,7 +1730,10 @@ fun DisplayRelaySet(
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 10.dp, bottom = 5.dp).weight(1f),
modifier =
Modifier
.padding(start = 10.dp, bottom = 5.dp)
.weight(1f),
)
Column(modifier = Modifier.padding(start = 10.dp)) {
@@ -1720,7 +1748,8 @@ fun DisplayRelaySet(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier.align(Alignment.BottomCenter)
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
@@ -1785,7 +1814,10 @@ fun DisplayPeopleList(
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center,
)
@@ -1810,7 +1842,8 @@ fun DisplayPeopleList(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier.align(Alignment.BottomCenter)
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
@@ -1840,7 +1873,10 @@ private fun RenderBadgeAward(
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
awardees.take(100).forEach { user ->
Row(
modifier = Modifier.size(size = Size35dp).clickable { nav("User/${user.pubkeyHex}") },
modifier =
Modifier
.size(size = Size35dp)
.clickable { nav("User/${user.pubkeyHex}") },
verticalAlignment = CenterVertically,
) {
ClickableUserPicture(
@@ -1952,7 +1988,10 @@ fun RenderPostApproval(
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center,
)
@@ -2076,7 +2115,10 @@ public fun RenderEmojiPack(
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1F).padding(5.dp),
modifier =
Modifier
.weight(1F)
.padding(5.dp),
textAlign = TextAlign.Center,
)
@@ -2116,7 +2158,8 @@ public fun RenderEmojiPack(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier.align(Alignment.BottomCenter)
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
@@ -2189,7 +2232,10 @@ fun RenderPinListEvent(
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center,
)
@@ -2221,7 +2267,8 @@ fun RenderPinListEvent(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier.align(Alignment.BottomCenter)
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
@@ -2320,7 +2367,8 @@ private fun RenderReport(
baseNote = it,
isQuotedNote = true,
modifier =
Modifier.padding(top = 5.dp)
Modifier
.padding(top = 5.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
@@ -2610,7 +2658,7 @@ fun MoreOptionsButton(
modifier = Size24Modifier,
onClick = enablePopup,
) {
VerticalDotsIcon()
VerticalDotsIcon(R.string.note_options)
NoteDropDownMenu(
baseNote,
@@ -2925,7 +2973,8 @@ fun BadgeDisplay(baseNote: Note) {
Row(
modifier =
Modifier.padding(10.dp)
Modifier
.padding(10.dp)
.clip(shape = CutCornerShape(20, 20, 20, 20))
.border(
5.dp,
@@ -2975,7 +3024,10 @@ private fun RenderBadge(
text = it,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
color = backgroundFromImage,
)
}
@@ -2985,7 +3037,10 @@ private fun RenderBadge(
text = it,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
@@ -3103,7 +3158,10 @@ fun VideoDisplay(
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(top = 5.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (isYouTube) {
@@ -3139,7 +3197,10 @@ fun VideoDisplay(
fontWeight = FontWeight.Bold,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(top = 5.dp),
)
}
@@ -3291,9 +3352,11 @@ fun AudioTrackHeader(
Row(
verticalAlignment = CenterVertically,
modifier =
Modifier.padding(top = 5.dp, start = 10.dp, end = 10.dp).clickable {
nav("User/${it.second.pubkeyHex}")
},
Modifier
.padding(top = 5.dp, start = 10.dp, end = 10.dp)
.clickable {
nav("User/${it.second.pubkeyHex}")
},
) {
ClickableUserPicture(it.second, 25.dp, accountViewModel)
Spacer(Modifier.width(5.dp))
@@ -3373,7 +3436,10 @@ fun AudioHeader(
content?.let {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(top = 5.dp),
) {
TranslatableRichTextViewer(
content = it,
@@ -3432,7 +3498,10 @@ fun RenderLiveActivityEventInner(
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = 5.dp).fillMaxWidth(),
modifier =
Modifier
.padding(vertical = 5.dp)
.fillMaxWidth(),
) {
subject?.let {
Text(
@@ -3492,7 +3561,10 @@ fun RenderLiveActivityEventInner(
} else {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(10.dp).height(100.dp),
modifier =
Modifier
.padding(10.dp)
.height(100.dp),
) {
Text(
text = stringResource(id = R.string.live_stream_is_offline),
@@ -3505,7 +3577,10 @@ fun RenderLiveActivityEventInner(
} else if (status == STATUS_ENDED) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(10.dp).height(100.dp),
modifier =
Modifier
.padding(10.dp)
.height(100.dp),
) {
Text(
text = stringResource(id = R.string.live_stream_has_ended),
@@ -3519,7 +3594,10 @@ fun RenderLiveActivityEventInner(
participantUsers.forEach {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = 5.dp).clickable { nav("User/${it.second.pubkeyHex}") },
modifier =
Modifier
.padding(vertical = 5.dp)
.clickable { nav("User/${it.second.pubkeyHex}") },
) {
ClickableUserPicture(it.second, 25.dp, accountViewModel)
Spacer(StdHorzSpacer)
@@ -3551,7 +3629,8 @@ private fun LongFormHeader(
Row(
modifier =
Modifier.padding(top = Size5dp)
Modifier
.padding(top = Size5dp)
.clip(shape = QuoteBorder)
.border(
1.dp,
@@ -3582,7 +3661,10 @@ private fun LongFormHeader(
Text(
text = it,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
)
}
@@ -3590,7 +3672,10 @@ private fun LongFormHeader(
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
@@ -3616,7 +3701,8 @@ private fun RenderClassifieds(
Row(
modifier =
Modifier.clip(shape = QuoteBorder)
Modifier
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
@@ -3674,7 +3760,12 @@ private fun RenderClassifieds(
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = remember { Modifier.clip(SmallBorder).padding(start = 5.dp) },
modifier =
remember {
Modifier
.clip(SmallBorder)
.padding(start = 5.dp)
},
)
}
}
@@ -3767,11 +3858,22 @@ fun CreateImageHeader(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = remember { Modifier.fillMaxWidth().height(150.dp) },
modifier =
remember {
Modifier
.fillMaxWidth()
.height(150.dp)
},
)
Box(
remember { Modifier.width(75.dp).height(75.dp).padding(10.dp).align(Alignment.BottomStart) },
remember {
Modifier
.width(75.dp)
.height(75.dp)
.padding(10.dp)
.align(Alignment.BottomStart)
},
) {
NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp)
}
@@ -379,9 +379,9 @@ private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableStat
label = "RenderShowIndividualReactionsButton",
) {
if (it) {
ExpandLessIcon(modifier = Size22Modifier)
ExpandLessIcon(modifier = Size22Modifier, R.string.close_all_reactions_to_this_post)
} else {
ExpandMoreIcon(modifier = Size22Modifier)
ExpandMoreIcon(modifier = Size22Modifier, R.string.open_all_reactions_to_this_post)
}
}
}
@@ -37,6 +37,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -87,7 +89,7 @@ private fun ShowMoreRelaysButton(onClick: () -> Unit) {
) {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
contentDescription = stringResource(id = R.string.expand_relay_list),
modifier = ShowMoreRelaysButtonIconModifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
@@ -119,7 +119,7 @@ fun ChatRelayExpandButton(onClick: () -> Unit) {
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
contentDescription = stringResource(id = R.string.expand_relay_list),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
@@ -156,7 +156,8 @@ fun RenderRelay(
val clickableModifier =
remember(relay) {
Modifier.padding(1.dp)
Modifier
.padding(1.dp)
.size(Size15dp)
.clickable(
role = Role.Button,
@@ -175,18 +176,21 @@ fun RenderRelay(
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
context.getString(
R.string.relay_information_document_error_assemble_url,
@@ -223,7 +227,7 @@ fun RenderRelayIcon(
RobohashFallbackAsyncImage(
robot = displayUrl,
model = iconUrl,
contentDescription = stringResource(id = R.string.relay_icon),
contentDescription = stringResource(id = R.string.relay_info, displayUrl),
colorFilter = RelayIconFilter,
modifier = iconModifier,
loadProfilePicture = loadProfilePicture,
@@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.LnZapEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
var nextAmount by mutableStateOf(TextFieldValue(""))
@@ -149,6 +150,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
try {
decodePublicKey(walletConnectPubkey.text.trim()).toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
@@ -168,6 +170,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
try {
unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() }
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
@@ -340,10 +340,11 @@ fun BaseUserPicture(
val myIconSize by remember(size) { derivedStateOf { size.div(3.5f) } }
Box(outerModifier, contentAlignment = Alignment.TopEnd) {
LoadUserProfilePicture(baseUser) { userProfilePicture ->
LoadUserProfilePicture(baseUser) { userProfilePicture, userName ->
InnerUserPicture(
userHex = baseUser.pubkeyHex,
userPicture = userProfilePicture,
userName = userName,
size = size,
modifier = innerModifier,
accountViewModel = accountViewModel,
@@ -357,17 +358,18 @@ fun BaseUserPicture(
@Composable
fun LoadUserProfilePicture(
baseUser: User,
innerContent: @Composable (String?) -> Unit,
innerContent: @Composable (String?, String?) -> Unit,
) {
val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture())
val userProfile by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
innerContent(userProfile)
innerContent(userProfile?.profilePicture(), userProfile?.bestDisplayName() ?: userProfile?.bestDisplayName())
}
@Composable
fun InnerUserPicture(
userHex: String,
userPicture: String?,
userName: String?,
size: Dp,
modifier: Modifier,
accountViewModel: AccountViewModel,
@@ -386,7 +388,12 @@ fun InnerUserPicture(
RobohashFallbackAsyncImage(
robot = userHex,
model = userPicture,
contentDescription = stringResource(id = R.string.profile_image),
contentDescription =
if (userName != null) {
stringResource(id = R.string.profile_image_of_user, userName)
} else {
stringResource(id = R.string.profile_image)
},
modifier = myImageModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = automaticallyShowProfilePicture,
@@ -275,7 +275,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
refreshChartModel()
}
suspend fun addToStatsSuspend(newNotes: Set<Note>) {
suspend fun addToStatsSuspend(newBlockNotes: Set<Set<Note>>) {
checkNotInMainThread()
val currentUser = user.pubkeyHex
@@ -287,39 +287,41 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
val takenIntoAccount = this.takenIntoAccount.toMutableSet()
var hasNewElements = false
newNotes.forEach {
val noteEvent = it.event
if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) {
if (noteEvent is ReactionEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
reactions[netDate] = (reactions[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) {
val netDate = formatDate(noteEvent.createdAt())
boosts[netDate] = (boosts[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is LnZapEvent) {
if (
noteEvent.isTaggedUser(currentUser)
) { // && noteEvent.pubKey != currentUser User might be sending his own receipts
val netDate = formatDate(noteEvent.createdAt)
zaps[netDate] =
(zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is TextNoteEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
replies[netDate] = (replies[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
newBlockNotes.forEach { newNotes ->
newNotes.forEach {
val noteEvent = it.event
if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) {
if (noteEvent is ReactionEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
reactions[netDate] = (reactions[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) {
val netDate = formatDate(noteEvent.createdAt())
boosts[netDate] = (boosts[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is LnZapEvent) {
if (
noteEvent.isTaggedUser(currentUser)
) { // && noteEvent.pubKey != currentUser User might be sending his own receipts
val netDate = formatDate(noteEvent.createdAt)
zaps[netDate] =
(zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
} else if (noteEvent is TextNoteEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
replies[netDate] = (replies[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id())
hasNewElements = true
}
}
}
}
@@ -422,7 +424,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it.flatten().toSet()) }
bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it) }
}
override fun onCleared() {
@@ -213,7 +213,10 @@ fun DrawPlayName(name: String) {
@Composable
fun DrawPlayNameIcon(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = StdButtonSizeModifier) {
PlayIcon(modifier = StdButtonSizeModifier, tint = MaterialTheme.colorScheme.placeholderText)
PlayIcon(
modifier = StdButtonSizeModifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
@@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
class ZapOptionstViewModel : ViewModel() {
private var account: Account? = null
@@ -104,11 +105,7 @@ class ZapOptionstViewModel : ViewModel() {
}
fun value(): Long? {
return try {
customAmount.text.trim().toLongOrNull()
} catch (e: Exception) {
null
}
return customAmount.text.trim().toLongOrNull()
}
fun cancel() {}
@@ -283,7 +280,7 @@ fun ZapButton(
onPost: () -> Unit,
) {
Button(
onClick = { onPost() },
onClick = { if (isActive) onPost() },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
@@ -446,6 +443,7 @@ fun payViaIntent(
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
if (e is CancellationException) throw e
if (e.message != null) {
onError(context.getString(R.string.no_wallet_found_with_error, e.message!!))
} else {
@@ -30,6 +30,7 @@ import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.encoders.Nip19
import kotlinx.coroutines.CancellationException
@Composable
fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
@@ -51,6 +52,7 @@ fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
onScan(null)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.e("NIP19 Scanner", "Error parsing $it", e)
// QR can be anything, do not throw errors.
onScan(null)
@@ -52,7 +52,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import kotlinx.coroutines.launch
import kotlinx.coroutines.CancellationException
@Composable
fun AccountScreen(
@@ -144,6 +144,7 @@ fun LoggedInPage(
activity.prepareToLaunchSigner()
launcher.launch(it)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Signer", "Error opening Signer app", e)
accountViewModel.toast(
R.string.error_opening_external_signer,
@@ -28,8 +28,9 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.Nip19
@@ -40,6 +41,7 @@ import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -85,7 +87,7 @@ class AccountStateViewModel() : ViewModel() {
) = withContext(Dispatchers.IO) {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.hexToByteArray()
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
if (loginWithExternalSigner && pubKeyParsed == null) {
throw Exception("Invalid key while trying to login with external signer")
@@ -192,6 +194,42 @@ class AccountStateViewModel() : ViewModel() {
}
}
fun login(
key: String,
password: String,
useProxy: Boolean,
proxyPort: Int,
loginWithExternalSigner: Boolean = false,
packageName: String = "",
onError: (String?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
if (key.startsWith("ncryptsec")) {
val newKey =
try {
CryptoUtils.decryptNIP49(key, password)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(e.message)
return@launch
}
if (newKey == null) {
onError("Could not decrypt key with provided password")
Log.e("Login", "Could not decrypt ncryptsec")
} else {
loginSync(newKey, useProxy, proxyPort, loginWithExternalSigner, packageName) {
onError(null)
}
}
} else {
loginSync(key, useProxy, proxyPort, loginWithExternalSigner, packageName) {
onError(null)
}
}
}
}
fun login(
key: String,
useProxy: Boolean,
@@ -201,12 +239,24 @@ class AccountStateViewModel() : ViewModel() {
onError: () -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
try {
loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName)
} catch (e: Exception) {
Log.e("Login", "Could not sign in", e)
onError()
}
loginSync(key, useProxy, proxyPort, loginWithExternalSigner, packageName, onError)
}
}
suspend fun loginSync(
key: String,
useProxy: Boolean,
proxyPort: Int,
loginWithExternalSigner: Boolean = false,
packageName: String = "",
onError: () -> Unit,
) {
try {
loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Login", "Could not sign in", e)
onError()
}
}
@@ -216,7 +266,7 @@ class AccountStateViewModel() : ViewModel() {
name: String? = null,
) {
viewModelScope.launch(Dispatchers.IO) {
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
val keyPair = KeyPair()
val account =
Account(
@@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -127,6 +128,8 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -284,26 +287,27 @@ fun Modifier.drawReplyLevel(
color: Color,
selected: Color,
): Modifier =
this.drawBehind {
val paddingDp = 2
val strokeWidthDp = 2
val levelWidthDp = strokeWidthDp + 1
this
.drawBehind {
val paddingDp = 2
val strokeWidthDp = 2
val levelWidthDp = strokeWidthDp + 1
val padding = paddingDp.dp.toPx()
val strokeWidth = strokeWidthDp.dp.toPx()
val levelWidth = levelWidthDp.dp.toPx()
val padding = paddingDp.dp.toPx()
val strokeWidth = strokeWidthDp.dp.toPx()
val levelWidth = levelWidthDp.dp.toPx()
repeat(level) {
this.drawLine(
if (it == level - 1) selected else color,
Offset(padding + it * levelWidth, 0f),
Offset(padding + it * levelWidth, size.height),
strokeWidth = strokeWidth,
)
repeat(level) {
this.drawLine(
if (it == level - 1) selected else color,
Offset(padding + it * levelWidth, 0f),
Offset(padding + it * levelWidth, size.height),
strokeWidth = strokeWidth,
)
}
return@drawBehind
}
return@drawBehind
}
.padding(start = (2 + (level * 3)).dp)
@OptIn(ExperimentalFoundationApi::class)
@@ -353,11 +357,14 @@ fun NoteMaster(
)
} else {
Column(
modifier.fillMaxWidth().padding(top = 10.dp),
modifier
.fillMaxWidth()
.padding(top = 10.dp),
) {
Row(
modifier =
Modifier.padding(start = 12.dp, end = 12.dp)
Modifier
.padding(start = 12.dp, end = 12.dp)
.clickable(onClick = { note.author?.let { nav("User/${it.pubkeyHex}") } }),
) {
NoteAuthorPicture(
@@ -391,13 +398,13 @@ fun NoteMaster(
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
modifier = Size24Modifier,
onClick = enablePopup,
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
@@ -443,7 +450,8 @@ fun NoteMaster(
Row(
modifier =
Modifier.padding(horizontal = 12.dp)
Modifier
.padding(horizontal = 12.dp)
.combinedClickable(
onClick = {},
onLongClick = { popupExpanded = true },
@@ -669,7 +677,9 @@ private fun RenderClassifiedsReaderForThread(
}
Row(
Modifier.padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp).fillMaxWidth(),
Modifier
.padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@@ -686,7 +696,9 @@ private fun RenderClassifiedsReaderForThread(
Row(
modifier =
Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp).fillMaxWidth(),
Modifier
.padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -703,6 +715,7 @@ private fun RenderClassifiedsReaderForThread(
}
var message by remember { mutableStateOf(TextFieldValue(msg)) }
val scope = rememberCoroutineScope()
TextField(
value = message,
@@ -725,7 +738,11 @@ private fun RenderClassifiedsReaderForThread(
isActive = message.text.isNotBlank(),
modifier = EditFieldTrailingIconModifier,
) {
note.author?.let { nav(routeToMessage(it, msg, accountViewModel)) }
scope.launch(Dispatchers.IO) {
note.author?.let {
nav(routeToMessage(it, note.toNostrUri() + "\n\n" + msg, accountViewModel))
}
}
}
},
colors =
@@ -36,23 +36,48 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.MutableState
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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -67,10 +92,14 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.note.authenticate
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNsec
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AccountBackupDialog(
accountViewModel: AccountViewModel,
@@ -80,12 +109,23 @@ fun AccountBackupDialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(modifier = Modifier.fillMaxSize()) {
Surface(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxSize(),
modifier =
Modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -93,7 +133,10 @@ fun AccountBackupDialog(
}
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp),
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -101,13 +144,105 @@ fun AccountBackupDialog(
style = RichTextStyle().resolveDefaults(),
) {
Markdown(
content = stringResource(R.string.account_backup_tips_md),
content = stringResource(R.string.account_backup_tips2_md),
)
}
Spacer(modifier = Modifier.height(30.dp))
Spacer(modifier = Modifier.height(10.dp))
NSecCopyButton(accountViewModel)
Spacer(modifier = Modifier.height(30.dp))
Material3RichText(
style = RichTextStyle().resolveDefaults(),
) {
Markdown(
content = stringResource(R.string.account_backup_tips3_md),
)
}
Spacer(modifier = Modifier.height(10.dp))
val password = remember { mutableStateOf(TextFieldValue("")) }
var errorMessage by remember { mutableStateOf("") }
var showCharsPassword by remember { mutableStateOf(false) }
val autofillNode =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { password.value = TextFieldValue(it) },
)
val autofill = LocalAutofill.current
LocalAutofillTree.current += autofillNode
OutlinedTextField(
modifier =
Modifier
.onGloballyPositioned { coordinates ->
autofillNode.boundingBox = coordinates.boundsInWindow()
}
.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
},
value = password.value,
onValueChange = {
password.value = it
if (errorMessage.isNotEmpty()) {
errorMessage = ""
}
},
keyboardOptions =
KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
placeholder = {
Text(
text = stringResource(R.string.ncryptsec_password),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
Row {
IconButton(onClick = { showCharsPassword = !showCharsPassword }) {
Icon(
imageVector =
if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription =
if (showCharsPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password,
)
},
)
}
}
},
visualTransformation =
if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(),
)
if (errorMessage.isNotBlank()) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(10.dp))
EncryptNSecCopyButton(accountViewModel, password)
}
}
}
@@ -160,6 +295,56 @@ private fun NSecCopyButton(accountViewModel: AccountViewModel) {
}
}
@Composable
private fun EncryptNSecCopyButton(
accountViewModel: AccountViewModel,
password: MutableState<TextFieldValue>,
) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager)
}
}
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
authenticate(
title = context.getString(R.string.copy_my_secret_key),
context = context,
keyguardLauncher = keyguardLauncher,
onApproved = { encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) },
onError = { title, message -> accountViewModel.toast(title, message) },
)
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ButtonPadding,
enabled = password.value.text.isNotBlank(),
) {
Icon(
tint = MaterialTheme.colorScheme.onPrimary,
imageVector = Icons.Default.Key,
contentDescription =
stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup),
modifier = Modifier.padding(end = 5.dp),
)
Text(
stringResource(id = R.string.encrypt_and_copy_my_secret_key),
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
fun Context.getFragmentActivity(): FragmentActivity? {
var currentContext = this
while (currentContext is ContextWrapper) {
@@ -189,3 +374,46 @@ private fun copyNSec(
}
}
}
private fun encryptCopyNSec(
password: MutableState<TextFieldValue>,
context: Context,
scope: CoroutineScope,
accountViewModel: AccountViewModel,
clipboardManager: ClipboardManager,
) {
if (password.value.text.isBlank()) {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.password_is_required),
Toast.LENGTH_SHORT,
)
.show()
}
} else {
accountViewModel.account.keyPair.privKey?.let {
val key = CryptoUtils.encryptNIP49(it.toHexKey(), password.value.text)
if (key != null) {
clipboardManager.setText(AnnotatedString(key))
scope.launch {
Toast.makeText(
context,
context.getString(R.string.secret_key_copied_to_clipboard),
Toast.LENGTH_SHORT,
)
.show()
}
} else {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.failed_to_encrypt_key),
Toast.LENGTH_SHORT,
)
.show()
}
}
}
}
}
@@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -87,6 +88,7 @@ import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
@@ -103,7 +105,11 @@ import kotlin.time.measureTimedValue
@Immutable class StringToastMsg(val title: String, val msg: String) : ToastMsg()
@Immutable class ResourceToastMsg(val titleResId: Int, val resourceId: Int) : ToastMsg()
@Immutable class ResourceToastMsg(
val titleResId: Int,
val resourceId: Int,
val params: Array<out String>? = null,
) : ToastMsg()
@Stable
class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao {
@@ -139,6 +145,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) }
}
fun toast(
titleResId: Int,
resourceId: Int,
vararg params: String,
) {
viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId, params)) }
}
fun isWriteable(): Boolean {
return account.isWriteable()
}
@@ -1042,7 +1056,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
) {
viewModelScope.launch(Dispatchers.IO) {
account.proxyPort = portNumber.value.toInt()
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
account.proxy = HttpClientManager.initProxy(checked, "127.0.0.1", account.proxyPort)
account.saveable.invalidateData()
serviceManager?.forceRestart()
}
@@ -1102,6 +1116,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
val myCover = context.imageLoader.execute(request).drawable
onReady(myCover)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("VideoView", "Fail to load cover $thumbUri", e)
onError(e.message)
}
@@ -1173,6 +1188,55 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
)
}
}
fun unwrapIfNeeded(
event: EventInterface?,
onReady: (Note) -> Unit,
) {
when (event) {
is GiftWrapEvent -> {
event.cachedGift(account.signer) {
val existingNote = LocalCache.getNoteIfExists(it.id)
if (existingNote != null) {
unwrapIfNeeded(existingNote.event, onReady)
} else {
LocalCache.verifyAndConsume(it, null)
unwrapIfNeeded(it, onReady)
}
}
}
is SealedGossipEvent -> {
event.cachedGossip(account.signer) {
val existingNote = LocalCache.getNoteIfExists(it.id)
if (existingNote != null) {
unwrapIfNeeded(existingNote.event, onReady)
} else {
// this is not verifiable
LocalCache.justConsume(it, null)
unwrapIfNeeded(it, onReady)
}
}
}
else -> {
event?.id()?.let {
LocalCache.getNoteIfExists(it)?.let {
onReady(it)
}
}
}
}
}
fun unwrapIfNeeded(
note: Note?,
onReady: (Note) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
unwrapIfNeeded(note?.event) {
onReady(it)
}
}
}
}
class HasNotificationDot(bottomNavigationItems: ImmutableList<Route>) {
@@ -38,10 +38,12 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.EditNote
@@ -112,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.ServerOption
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
@@ -156,6 +159,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.events.findURLs
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -268,7 +272,13 @@ fun ChannelScreen(
val replyTo = remember { mutableStateOf<Note?>(null) }
Column(
modifier = remember { Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true) },
modifier =
remember {
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
},
) {
if (channel is LiveActivitiesChannel) {
ShowVideoStreaming(channel, accountViewModel)
@@ -300,6 +310,10 @@ fun ChannelScreen(
dao = accountViewModel,
)
tagger.run()
val urls = findURLs(tagger.message)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
if (channel is PublicChatChannel) {
accountViewModel.account.sendChannelMessage(
message = tagger.message,
@@ -307,6 +321,7 @@ fun ChannelScreen(
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
)
} else if (channel is LiveActivitiesChannel) {
accountViewModel.account.sendLiveMessage(
@@ -315,6 +330,7 @@ fun ChannelScreen(
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
)
}
newPostModel.message = TextFieldValue("")
@@ -333,7 +349,11 @@ fun DisplayReplyingToNote(
onCancel: () -> Unit,
) {
Row(
Modifier.padding(horizontal = 10.dp).animateContentSize(),
Modifier
.padding(horizontal = 10.dp)
.heightIn(max = 100.dp)
.verticalScroll(rememberScrollState())
.animateContentSize(),
verticalAlignment = Alignment.CenterVertically,
) {
if (replyingNote != null) {
@@ -356,7 +376,10 @@ fun DisplayReplyingToNote(
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(end = 5.dp).size(30.dp),
modifier =
Modifier
.padding(end = 5.dp)
.size(30.dp),
tint = MaterialTheme.colorScheme.placeholderText,
)
}
@@ -372,13 +395,13 @@ fun EditFieldRow(
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit,
) {
Row(
Column(
modifier = EditFieldModifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val context = LocalContext.current
ShowUserSuggestionList(channelScreenModel, accountViewModel)
MyTextField(
value = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) },
@@ -387,7 +410,7 @@ fun EditFieldRow(
capitalization = KeyboardCapitalization.Sentences,
),
shape = EditFieldBorder,
modifier = Modifier.weight(1f, true),
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.reply_here),
@@ -424,6 +447,7 @@ fun EditFieldRow(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
)
}
}
@@ -718,7 +742,11 @@ fun ShortChannelHeader(
}
Column(
modifier = Modifier.padding(start = 10.dp).height(35.dp).weight(1f),
modifier =
Modifier
.padding(start = 10.dp)
.height(35.dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -731,7 +759,10 @@ fun ShortChannelHeader(
}
Row(
modifier = Modifier.height(Size35dp).padding(start = 5.dp),
modifier =
Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (channel is PublicChatChannel) {
@@ -1021,7 +1052,12 @@ fun LiveFlag() {
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
modifier =
remember { Modifier.clip(SmallBorder).background(Color.Red).padding(horizontal = 5.dp) },
remember {
Modifier
.clip(SmallBorder)
.background(Color.Red)
.padding(horizontal = 5.dp)
},
)
}
@@ -1032,7 +1068,12 @@ fun EndedFlag() {
color = Color.White,
fontWeight = FontWeight.Bold,
modifier =
remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) },
remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp)
},
)
}
@@ -1043,7 +1084,12 @@ fun OfflineFlag() {
color = Color.White,
fontWeight = FontWeight.Bold,
modifier =
remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) },
remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp)
},
)
}
@@ -1057,7 +1103,12 @@ fun ScheduledFlag(starts: Long?) {
color = Color.White,
fontWeight = FontWeight.Bold,
modifier =
remember { Modifier.clip(SmallBorder).background(Color.Black).padding(horizontal = 5.dp) },
remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp)
},
)
}
@@ -1066,7 +1117,10 @@ private fun NoteCopyButton(note: Channel) {
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { popupExpanded = true },
shape = ButtonBorder,
colors =
@@ -1109,7 +1163,10 @@ private fun EditButton(
}
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { wantsToPost = true },
contentPadding = ZeroPadding,
) {
@@ -225,6 +225,7 @@ fun ChatroomListScreenOnlyList(
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.account = accountViewModel.account
NostrChatroomListDataSource.start()
}
}
@@ -303,6 +304,7 @@ fun WatchAccountForListScreen(
) {
LaunchedEffect(accountViewModel) {
launch(Dispatchers.IO) {
NostrChatroomListDataSource.account = accountViewModel.account
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData(true)
newFeedViewModel.invalidateData(true)
@@ -25,11 +25,13 @@ import androidx.compose.animation.Crossfade
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -92,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.ServerOption
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DisplayRoomSubject
@@ -115,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.findURLs
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
@@ -324,6 +328,9 @@ fun ChatroomScreen(
// LAST ROW
PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) {
scope.launch(Dispatchers.IO) {
val urls = findURLs(newPostModel.message.text)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) {
accountViewModel.account.sendNIP24PrivateMessage(
message = newPostModel.message.text,
@@ -331,6 +338,7 @@ fun ChatroomScreen(
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
)
} else {
accountViewModel.account.sendPrivateMessage(
@@ -339,6 +347,7 @@ fun ChatroomScreen(
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
)
}
@@ -357,13 +366,13 @@ fun PrivateMessageEditFieldRow(
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit,
) {
Row(
Column(
modifier = EditFieldModifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val context = LocalContext.current
ShowUserSuggestionList(channelScreenModel, accountViewModel)
MyTextField(
value = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) },
@@ -372,7 +381,7 @@ fun PrivateMessageEditFieldRow(
capitalization = KeyboardCapitalization.Sentences,
),
shape = EditFieldBorder,
modifier = Modifier.weight(1f, true),
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.reply_here),
@@ -455,10 +464,36 @@ fun PrivateMessageEditFieldRow(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
)
}
}
@Composable
fun ShowUserSuggestionList(
channelScreenModel: NewPostViewModel,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
) {
val userSuggestions = channelScreenModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding =
PaddingValues(
top = 10.dp,
),
modifier = modifier,
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex },
) { _, item ->
UserLine(item, accountViewModel) { channelScreenModel.autocompleteWithUser(item) }
}
}
}
}
@Composable
fun NewFeatureNIP24AlertDialog(
accountViewModel: AccountViewModel,
@@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.RichTextDefaults
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CancellationException
@Composable
fun ConnectOrbotDialog(
@@ -83,7 +84,8 @@ fun ConnectOrbotDialog(
onPost = {
try {
Integer.parseInt(portNumber.value)
} catch (_: Exception) {
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(toastMessage)
return@UseOrbotButton
}
@@ -45,7 +45,6 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
@@ -270,6 +270,7 @@ fun MainScreen(
videoFeedViewModel.sendToTop()
}
Route.Discover.base -> {
discoverMarketplaceFeedViewModel.sendToTop()
discoveryLiveFeedViewModel.sendToTop()
discoveryCommunityFeedViewModel.sendToTop()
discoveryChatFeedViewModel.sendToTop()
@@ -472,12 +473,22 @@ private fun DisplayErrorMessages(accountViewModel: AccountViewModel) {
openDialogMsg.value?.let { obj ->
when (obj) {
is ResourceToastMsg ->
InformationDialog(
context.getString(obj.titleResId),
context.getString(obj.resourceId),
) {
accountViewModel.clearToasts()
if (obj.params != null) {
InformationDialog(
context.getString(obj.titleResId),
context.getString(obj.resourceId, *obj.params),
) {
accountViewModel.clearToasts()
}
} else {
InformationDialog(
context.getString(obj.titleResId),
context.getString(obj.resourceId),
) {
accountViewModel.clearToasts()
}
}
is StringToastMsg ->
InformationDialog(
obj.title,
@@ -155,7 +155,9 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -390,7 +392,10 @@ private fun RenderSurface(
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
Column(
modifier = Modifier.fillMaxSize().onSizeChanged { columnSize = it },
modifier =
Modifier
.fillMaxSize()
.onSizeChanged { columnSize = it },
) {
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
@@ -403,7 +408,8 @@ private fun RenderSurface(
Box(
modifier =
remember {
Modifier.verticalScroll(scrollState)
Modifier
.verticalScroll(scrollState)
.nestedScroll(
object : NestedScrollConnection {
override fun onPreScroll(
@@ -726,10 +732,17 @@ private fun ProfileHeader(
DrawBanner(baseUser, accountViewModel)
Box(
modifier = Modifier.padding(horizontal = 10.dp).size(40.dp).align(Alignment.TopEnd),
modifier =
Modifier
.padding(horizontal = 10.dp)
.size(40.dp)
.align(Alignment.TopEnd),
) {
Button(
modifier = Modifier.size(30.dp).align(Alignment.Center),
modifier =
Modifier
.size(30.dp)
.align(Alignment.Center),
onClick = { popupExpanded = true },
shape = ButtonBorder,
colors =
@@ -754,7 +767,11 @@ private fun ProfileHeader(
}
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.padding(top = 75.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -789,7 +806,10 @@ private fun ProfileHeader(
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier.height(Size35dp).padding(bottom = 3.dp),
modifier =
Modifier
.height(Size35dp)
.padding(bottom = 3.dp),
) {
MessageButton(baseUser, accountViewModel, nav)
@@ -969,13 +989,16 @@ private fun DrawAdditionalInfo(
)
IconButton(
modifier = Modifier.size(25.dp).padding(start = 5.dp),
modifier =
Modifier
.size(25.dp)
.padding(start = 5.dp),
onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) },
) {
Icon(
imageVector = Icons.Default.ContentCopy,
null,
modifier = Modifier.size(15.dp),
contentDescription = stringResource(id = R.string.copy_npub_to_clipboard),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
@@ -995,13 +1018,13 @@ private fun DrawAdditionalInfo(
}
IconButton(
modifier = Modifier.size(25.dp),
modifier = Size25Modifier,
onClick = { dialogOpen = true },
) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(15.dp),
contentDescription = stringResource(id = R.string.show_npub_as_a_qr_code),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
@@ -1059,7 +1082,10 @@ private fun DrawAdditionalInfo(
text = AnnotatedString(identity.identity),
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f),
modifier =
Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
.weight(1f),
)
}
}
@@ -1131,7 +1157,10 @@ fun DisplayLNAddress(
text = AnnotatedString(lud16),
onClick = { zapExpanded = !zapExpanded },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f),
modifier =
Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
.weight(1f),
)
}
@@ -1223,12 +1252,21 @@ private fun WatchApp(
appLogo?.let {
Box(
remember { Modifier.size(Size35dp).clickable { nav("Note/${baseApp.idHex}") } },
remember {
Modifier
.size(Size35dp)
.clickable { nav("Note/${baseApp.idHex}") }
},
) {
AsyncImage(
model = appLogo,
contentDescription = null,
modifier = remember { Modifier.size(Size35dp).clip(shape = CircleShape) },
modifier =
remember {
Modifier
.size(Size35dp)
.clip(shape = CircleShape)
},
)
}
}
@@ -1343,7 +1381,11 @@ fun BadgeThumb(
onClick: ((String) -> Unit)? = null,
) {
Box(
remember { Modifier.width(size).height(size) },
remember {
Modifier
.width(size)
.height(size)
},
) {
WatchAndRenderBadgeImage(baseNote, loadProfilePicture, size, pictureModifier, onClick)
}
@@ -1373,7 +1415,13 @@ private fun WatchAndRenderBadgeImage(
RobohashAsyncImage(
robot = "authornotfound",
contentDescription = stringResource(R.string.unknown_author),
modifier = remember { pictureModifier.width(size).height(size).background(bgColor) },
modifier =
remember {
pictureModifier
.width(size)
.height(size)
.background(bgColor)
},
)
} else {
RobohashFallbackAsyncImage(
@@ -1418,7 +1466,8 @@ fun DrawBanner(
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.height(125.dp)
.combinedClickable(
onClick = { zoomImageDialogOpen = true },
@@ -1438,7 +1487,10 @@ fun DrawBanner(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(id = R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(125.dp),
modifier =
Modifier
.fillMaxWidth()
.height(125.dp),
)
}
}
@@ -1692,7 +1744,10 @@ private fun MessageButton(
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = {
scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav("Room/$it") } }
},
@@ -1727,7 +1782,10 @@ private fun InnerEditButtonPreview() {
@Composable
private fun InnerEditButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = onClick,
contentPadding = ZeroPadding,
) {

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