Compare commits

...

142 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 Pamplona
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 Pamplona
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
fiatjaf
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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 Pamplona
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
Vitor Pamplona
ac8f0e8600 v0.83.7 Testing new build tools 2024-01-11 12:33:55 -05:00
Vitor Pamplona
21472daab5 Uses new build tools to sign releases 2024-01-11 12:32:58 -05:00
Vitor Pamplona
c6a00386c8 adds distribution to github actions 2024-01-11 12:23:36 -05:00
Vitor Pamplona
9e3aa31c25 Updates Github actions scripts. 2024-01-11 12:06:45 -05:00
Vitor Pamplona
47987fac0f Merge pull request #743 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-11 10:52:13 -05:00
Crowdin Bot
46c782cf37 New Crowdin translations by GitHub Action 2024-01-11 15:51:24 +00:00
Vitor Pamplona
fd67f4875d Merge pull request #742 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-11 10:50:28 -05:00
Vitor Pamplona
4e2ff591e6 v0.83.6 2024-01-11 10:49:37 -05:00
Crowdin Bot
0e46145e2d New Crowdin translations by GitHub Action 2024-01-11 15:45:38 +00:00
Vitor Pamplona
383d859544 Fixes the BookmarkScreen update after adding and removing a bookmark to the list 2024-01-11 10:43:51 -05:00
Vitor Pamplona
81400eb863 Solving viewModel creation bug for Public Bookmarks 2024-01-11 10:36:52 -05:00
Vitor Pamplona
422446f53e Fixes bug when creating bookmarks for the first time. 2024-01-11 10:09:41 -05:00
Vitor Pamplona
f1e721f323 Fixes bug in checking bookmarks without running the onReady. 2024-01-11 10:09:19 -05:00
Vitor Pamplona
0fc72eb84d updates mockk 2024-01-11 09:43:11 -05:00
Vitor Pamplona
9234f4b7a8 Improves previews 2024-01-09 17:47:34 -05:00
Vitor Pamplona
0198d56099 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-01-09 16:35:04 -05:00
Vitor Pamplona
8f31c60cd1 Fixing some of the margins in the login screen. 2024-01-09 16:33:50 -05:00
Vitor Pamplona
7cb17954d9 Merge pull request #741 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-01-09 16:26:37 -05:00
Crowdin Bot
baf35b202f New Crowdin translations by GitHub Action 2024-01-09 21:25:47 +00:00
Vitor Pamplona
7b7e3624ac New Signup screen 2024-01-09 16:23:17 -05:00
Vitor Pamplona
d27b9ae5a8 Reducing the size of the following icon 2024-01-06 19:16:15 -05:00
198 changed files with 5608 additions and 1529 deletions

View File

@@ -12,15 +12,17 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v2
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v1
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
- name: Cache gradle
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}

View File

@@ -10,15 +10,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v1
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
- name: Cache gradle
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
@@ -36,6 +37,9 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "34.0.0"
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
@@ -45,6 +49,9 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "34.0.0"
- name: Build APK
run: ./gradlew assembleRelease --stacktrace
@@ -57,6 +64,9 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "34.0.0"
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
@@ -66,6 +76,9 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "34.0.0"
- name: Create Release
id: create_release

View File

@@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Checkout code
uses: actions/checkout@v4
- name: crowdin action
uses: crowdin/github-action@v1

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@
/.idea/deploymentTargetDropDown.xml
/.idea/appInsightsSettings.xml
/.idea/ktlint-plugin.xml
/.idea/ktfmt.xml
.DS_Store
/build
/captures

2
.idea/kotlinc.xml generated
View File

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

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">

View File

@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 346
versionName "0.83.5"
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)
@@ -274,9 +278,9 @@ dependencies {
implementation 'id.zelory:compressor:3.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.8'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02'
testImplementation 'io.mockk:mockk:1.13.9'
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"

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 { *; }

View File

@@ -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}")
}
}

View File

@@ -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.")
}
}

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 -->

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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
@@ -130,8 +131,11 @@ object LocalPreferences {
return currentAccount
}
private suspend fun updateCurrentAccount(npub: String) {
if (currentAccount != npub) {
private fun updateCurrentAccount(npub: String?) {
if (npub == null) {
currentAccount = null
encryptedPreferences().edit().clear().apply()
} else if (currentAccount != npub) {
currentAccount = npub
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
@@ -229,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)
}
}
@@ -250,7 +254,7 @@ object LocalPreferences {
deleteUserPreferenceFile(accountInfo.npub)
if (savedAccounts().isEmpty()) {
encryptedPreferences().edit().clear().apply()
updateCurrentAccount(null)
} else if (currentAccount() == accountInfo.npub) {
updateCurrentAccount(savedAccounts().elementAt(0).npub)
}
@@ -365,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)}",
@@ -507,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]
@@ -518,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)}",
@@ -538,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)}",
@@ -553,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)}",
@@ -567,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)) {
@@ -585,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)}",

View File

@@ -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
}
}

View File

@@ -66,7 +66,6 @@ import com.vitorpamplona.quartz.events.GeneralListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.events.IdentityClaim
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
@@ -455,11 +454,21 @@ class Account(
val liveHiddenUsers = flowHiddenUsers.asLiveData()
val decryptBookmarks: LiveData<BookmarkListEvent> by lazy {
val decryptBookmarks: LiveData<BookmarkListEvent?> by lazy {
userProfile().live().innerBookmarks.switchMap { userState ->
liveData(Dispatchers.IO) {
userState.user.latestBookmarkList?.privateTags(signer) {
scope.launch(Dispatchers.IO) { userState.user.latestBookmarkList?.let { emit(it) } }
if (userState.user.latestBookmarkList == null) {
emit(null)
} else {
emit(
withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
userState.user.latestBookmarkList?.privateTags(signer) {
continuation.resume(userState.user.latestBookmarkList)
}
}
},
)
}
}
}
@@ -541,14 +550,36 @@ class Account(
}
}
suspend fun sendNewUserMetadata(
toString: String,
newName: String,
identities: List<IdentityClaim>,
fun sendNewUserMetadata(
name: String? = null,
picture: String? = null,
banner: String? = null,
website: String? = null,
about: String? = null,
nip05: String? = null,
lnAddress: String? = null,
lnURL: String? = null,
twitter: String? = null,
mastodon: String? = null,
github: String? = null,
) {
if (!isWriteable()) return
MetadataEvent.create(toString, newName, identities, signer) {
MetadataEvent.updateFromPast(
latest = userProfile().info?.latestMetadata,
name = name,
picture = picture,
banner = banner,
website = website,
about = about,
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
twitter = twitter,
mastodon = mastodon,
github = github,
signer = signer,
) {
Client.send(it)
LocalCache.justConsume(it, null)
}
@@ -1294,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
@@ -1350,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
@@ -1397,7 +1428,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1430,7 +1461,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1464,6 +1495,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
sendPrivateMessage(
message,
@@ -1474,6 +1506,7 @@ class Account(
wantsToMarkAsSensitive,
zapRaiserAmount,
geohash,
nip94attachments,
)
}
@@ -1486,6 +1519,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1502,6 +1536,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
advertiseNip18 = false,
) {
@@ -1520,6 +1555,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
@@ -1536,6 +1572,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
) {
broadcastPrivately(it)
@@ -1763,7 +1800,14 @@ class Account(
note: Note,
onReady: (Boolean) -> Unit,
) {
if (!isWriteable()) return
if (!isWriteable()) {
onReady(false)
false
}
if (userProfile().latestBookmarkList == null) {
onReady(false)
false
}
if (note is AddressableNote) {
userProfile().latestBookmarkList?.privateTaggedAddresses(signer) {

View File

@@ -32,6 +32,7 @@ fun checkForHashtagWithIcon(
primary: Color,
): HashtagIcon? {
return when (tag.lowercase()) {
"₿itcoin",
"bitcoin",
"btc",
"timechain",

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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),

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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(

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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),

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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),

View File

@@ -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

View File

@@ -27,8 +27,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
@@ -39,8 +37,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.StringWriter
import kotlin.coroutines.cancellation.CancellationException
class NewUserMetadataViewModel : ViewModel() {
private lateinit var account: Account
@@ -97,59 +94,22 @@ class NewUserMetadataViewModel : ViewModel() {
fun create() {
// Tries to not delete any existing attribute that we do not work with.
val latest = account.userProfile().info?.latestMetadata
val currentJson =
if (latest != null) {
ObjectMapper()
.readTree(
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8)),
) as ObjectNode
} else {
ObjectMapper().createObjectNode()
}
currentJson.put("name", displayName.value.trim())
currentJson.put("display_name", displayName.value.trim())
currentJson.put("picture", picture.value.trim())
currentJson.put("banner", banner.value.trim())
currentJson.put("website", website.value.trim())
currentJson.put("about", about.value.trim())
currentJson.put("nip05", nip05.value.trim())
currentJson.put("lud16", lnAddress.value.trim())
currentJson.put("lud06", lnURL.value.trim())
var claims = latest?.identityClaims() ?: emptyList()
if (twitter.value.isBlank()) {
// delete twitter
claims = claims.filter { it !is TwitterIdentity }
}
if (github.value.isBlank()) {
// delete github
claims = claims.filter { it !is GitHubIdentity }
}
if (mastodon.value.isBlank()) {
// delete mastodon
claims = claims.filter { it !is MastodonIdentity }
}
// Updates while keeping other identities intact
val newClaims =
listOfNotNull(
TwitterIdentity.parseProofUrl(twitter.value),
GitHubIdentity.parseProofUrl(github.value),
MastodonIdentity.parseProofUrl(mastodon.value),
) +
claims.filter { it !is TwitterIdentity && it !is GitHubIdentity && it !is MastodonIdentity }
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
viewModelScope.launch(Dispatchers.IO) {
account.sendNewUserMetadata(writer.buffer.toString(), displayName.value.trim(), newClaims)
account.sendNewUserMetadata(
name = displayName.value,
picture = picture.value,
banner = banner.value,
website = website.value,
about = about.value,
nip05 = nip05.value,
lnAddress = lnAddress.value,
lnURL = lnURL.value,
twitter = twitter.value,
mastodon = mastodon.value,
github = github.value,
)
clear()
}
clear()
}
fun clear() {
@@ -238,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")

View File

@@ -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 =

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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 {

View File

@@ -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,
)

View File

@@ -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))
}
},

View File

@@ -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
}
}

View File

@@ -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,
),

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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) } }
}
}

View File

@@ -117,7 +117,7 @@ fun RobohashFallbackAsyncImage(
Image(
painter = base64Painter,
contentDescription = null,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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
},
)

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}

View File

@@ -35,12 +35,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ThemeComparison
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
@Preview
fun AddButtonPreview() {
ThemeComparison(
ThemeComparisonColumn(
onDark = {
Row {
Column {

View File

@@ -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 }

View File

@@ -27,13 +27,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.ThemeComparison
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
@Composable
@Preview
fun DisplayPoWPreview() {
ThemeComparison(
ThemeComparisonColumn(
onDark = { DisplayPoW(pow = 24) },
onLight = { DisplayPoW(pow = 24) },
)

View File

@@ -51,13 +51,13 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparison
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
@Preview
fun LeftPictureLayoutPreview() {
ThemeComparison(
ThemeComparisonColumn(
onDark = { LeftPictureLayoutPreviewCard() },
onLight = { LeftPictureLayoutPreviewCard() },
)

View File

@@ -73,13 +73,14 @@ import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
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
@@ -121,7 +122,7 @@ fun AccountSwitchBottomSheet(
) {
Surface(modifier = Modifier.fillMaxSize()) {
Box {
LoginPage(accountStateViewModel, isFirstLogin = false)
LoginOrSignupScreen(accountStateViewModel, isFirstLogin = false)
TopAppBar(
title = {
Text(text = stringResource(R.string.account_switch_add_account_dialog_title))
@@ -166,6 +167,7 @@ fun DisplayAccount(
decodePublicKey(acc.npub).toHexKey(),
)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}

View File

@@ -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)
},
)
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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()}"
}

View File

@@ -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 :

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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,
)
// }
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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,
)
}
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -50,9 +50,9 @@ import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
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(
@@ -71,7 +71,7 @@ fun AccountScreen(
LoadingAccounts()
}
is AccountState.LoggedOff -> {
LoginPage(accountStateViewModel, isFirstLogin = true)
LoginOrSignupScreen(accountStateViewModel, isFirstLogin = true)
}
is AccountState.LoggedIn -> {
CompositionLocalProvider(
@@ -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,

View File

@@ -28,7 +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
@@ -39,9 +41,11 @@ 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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -65,8 +69,7 @@ class AccountStateViewModel() : ViewModel() {
private suspend fun tryLoginExistingAccount() =
withContext(Dispatchers.IO) {
LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) }
?: run { requestLoginUI() }
LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } ?: run { requestLoginUI() }
}
private suspend fun requestLoginUI() {
@@ -84,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")
@@ -144,25 +147,33 @@ class AccountStateViewModel() : ViewModel() {
startUI(account)
}
suspend fun startUI(account: Account) =
withContext(Dispatchers.Main) {
if (account.isWriteable()) {
_accountContent.update { AccountState.LoggedIn(account) }
} else {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
// Prepares livedata objects on the main user.
account.userProfile().live()
}
serviceManager?.restartIfDifferentAccount(account)
}
account.saveable.observeForever(saveListener)
suspend fun startUI(
account: Account,
onServicesReady: (() -> Unit)? = null,
) = withContext(Dispatchers.Main) {
if (account.isWriteable()) {
_accountContent.update { AccountState.LoggedIn(account) }
} else {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
// Prepares livedata objects on the main user.
account.userProfile().live()
}
serviceManager?.restartIfDifferentAccount(account)
if (onServicesReady != null) {
// waits for the connection to go through
delay(1000)
onServicesReady()
}
}
account.saveable.observeForever(saveListener)
}
@OptIn(DelicateCoroutinesApi::class)
private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = {
GlobalScope.launch(Dispatchers.IO) { LocalPreferences.saveToEncryptedStorage(it.account) }
@@ -183,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,
@@ -192,21 +239,34 @@ 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()
}
}
fun newKey(
useProxy: Boolean,
proxyPort: Int,
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(
@@ -220,7 +280,10 @@ class AccountStateViewModel() : ViewModel() {
// saves to local preferences
LocalPreferences.updatePrefsForLogin(account)
startUI(account)
startUI(account) {
account.userProfile().latestContactList?.let { Client.send(it) }
account.sendNewUserMetadata(name = name)
}
}
}

View File

@@ -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 =

View File

@@ -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()
}
}
}
}
}

View File

@@ -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>) {

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