mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
133d7d5ce0 | ||
|
|
215989108b | ||
|
|
ed7dcf94f8 | ||
|
|
b2c7f150ec | ||
|
|
c85d74a688 | ||
|
|
cbf23fedf1 | ||
|
|
5ed64bd5b5 | ||
|
|
b79f9c8395 | ||
|
|
02da606461 | ||
|
|
7074446cae | ||
|
|
4746b5b18a | ||
|
|
103a61f35c | ||
|
|
d9c48d4039 | ||
|
|
9479e86b66 | ||
|
|
bb7ec1ee4b | ||
|
|
7a213545a2 | ||
|
|
48f27f656d | ||
|
|
47176421e0 | ||
|
|
9c84a538d6 | ||
|
|
8bc1c2ec68 | ||
|
|
b94c1e2792 | ||
|
|
74f0393706 | ||
|
|
0ec4b9b59f | ||
|
|
1592d55db5 | ||
|
|
629c8b8167 | ||
|
|
9d8f70e04b | ||
|
|
f75104e616 | ||
|
|
c9d59fd5ac | ||
|
|
d41eb4ce31 | ||
|
|
60e87d943a | ||
|
|
1deed59d5b | ||
|
|
bbc4ec2625 | ||
|
|
5109b65e37 | ||
|
|
c0a9ce8e21 | ||
|
|
bdff8c6c0d | ||
|
|
a3ac8a445d | ||
|
|
84d4d68116 | ||
|
|
27248bff17 | ||
|
|
dc9a9f288b | ||
|
|
88662a4e55 | ||
|
|
9d269f7cba | ||
|
|
04fe439e0b | ||
|
|
72503b9061 | ||
|
|
f7ab963db9 | ||
|
|
f97128e11c | ||
|
|
e2d4107297 | ||
|
|
18709a1b22 | ||
|
|
ddfe2abb42 | ||
|
|
0cf82f7edd | ||
|
|
d1c40efd90 | ||
|
|
68e90ce3ed | ||
|
|
95e19cf681 | ||
|
|
3298fd4493 | ||
|
|
248bdffd50 | ||
|
|
cce7a20918 | ||
|
|
845c99b798 | ||
|
|
b13b832696 | ||
|
|
eb6e235f1b | ||
|
|
36c49b2171 | ||
|
|
5cd3096221 | ||
|
|
5bdc59d0e3 | ||
|
|
be43f2025f | ||
|
|
0bb39f91b9 | ||
|
|
63a7b44264 | ||
|
|
b5341f67e0 | ||
|
|
3a2045eccc | ||
|
|
c78239a6b7 | ||
|
|
4be4306540 | ||
|
|
48887f23c1 | ||
|
|
103809d35b | ||
|
|
9d92976eb9 | ||
|
|
d3d19b497b | ||
|
|
316c32f076 | ||
|
|
6f27201244 | ||
|
|
e56377f8c3 | ||
|
|
44e04864ac | ||
|
|
eea727570c | ||
|
|
54155a3c30 | ||
|
|
34d373c293 | ||
|
|
d7563032c4 | ||
|
|
ed75489975 | ||
|
|
145b0d8f0a | ||
|
|
eae07e2fe0 | ||
|
|
6b60b1434f | ||
|
|
3ff4db264a | ||
|
|
21f042fa95 | ||
|
|
e26bbc2e61 | ||
|
|
5135026e6a | ||
|
|
a72f5ae01f | ||
|
|
f2ed2d94c4 | ||
|
|
75d703ac0a | ||
|
|
277bf0a603 | ||
|
|
1ac990b67c | ||
|
|
e59f676d47 | ||
|
|
312d82fdf6 | ||
|
|
08659738bd | ||
|
|
f1b0958903 | ||
|
|
998aa2aed1 | ||
|
|
370a18f716 | ||
|
|
6638defbb9 | ||
|
|
d88eb014b7 | ||
|
|
14e9a91d40 | ||
|
|
3ed45f282c | ||
|
|
2ff090dac1 | ||
|
|
281d86b4bf | ||
|
|
504e55a93f | ||
|
|
c7b2c20119 | ||
|
|
3e102c064b | ||
|
|
e0ec8a741c | ||
|
|
f1388b46d5 | ||
|
|
0f24e458d8 | ||
|
|
b8f3625954 | ||
|
|
0babc46f7c | ||
|
|
6d368148cc | ||
|
|
3241f0ada1 | ||
|
|
49ad972af8 | ||
|
|
e061575454 | ||
|
|
694642b42b | ||
|
|
f4684ed078 | ||
|
|
db12ff0532 | ||
|
|
13d9c1ab10 | ||
|
|
ac8f0e8600 | ||
|
|
21472daab5 | ||
|
|
c6a00386c8 | ||
|
|
9e3aa31c25 | ||
|
|
47987fac0f | ||
|
|
46c782cf37 | ||
|
|
fd67f4875d | ||
|
|
4e2ff591e6 | ||
|
|
0e46145e2d | ||
|
|
383d859544 | ||
|
|
81400eb863 | ||
|
|
422446f53e | ||
|
|
f1e721f323 | ||
|
|
0fc72eb84d | ||
|
|
9234f4b7a8 | ||
|
|
0198d56099 | ||
|
|
8f31c60cd1 | ||
|
|
7cb17954d9 | ||
|
|
baf35b202f | ||
|
|
7b7e3624ac | ||
|
|
d27b9ae5a8 |
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -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*') }}
|
||||
|
||||
19
.github/workflows/create-release.yml
vendored
19
.github/workflows/create-release.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -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
2
.idea/kotlinc.xml
generated
@@ -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>
|
||||
40
README.md
40
README.md
@@ -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
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
# 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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
5
app/proguard-rules.pro
vendored
5
app/proguard-rules.pro
vendored
@@ -33,6 +33,11 @@
|
||||
# JNA For Libsodium
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
|
||||
# libscrypt
|
||||
-keep class com.lambdaworks.codec.** { *; }
|
||||
-keep class com.lambdaworks.crypto.** { *; }
|
||||
-keep class com.lambdaworks.jni.** { *; }
|
||||
|
||||
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
|
||||
-keep class com.sun.jna.ToNativeConverter { *; }
|
||||
-keep class com.sun.jna.NativeMapped { *; }
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -60,6 +61,7 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
try {
|
||||
parseMessage(messageStr)?.let { receiveIfNew(it) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d(TAG, "Message could not be parsed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
object PushNotificationUtils {
|
||||
var hasInit: Boolean = false
|
||||
@@ -36,6 +37,7 @@ object PushNotificationUtils {
|
||||
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
<package android:name="com.greenart7c3.nostrsigner"/>
|
||||
<package android:name="com.greenart7c3.nostrsigner.debug"/>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrsigner" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<!-- Doesn't require a camera -->
|
||||
|
||||
@@ -26,6 +26,7 @@ import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
@@ -87,6 +88,10 @@ class Amethyst : Application() {
|
||||
return ImageLoader.Builder(applicationContext).diskCache { imageCache }
|
||||
}
|
||||
|
||||
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {
|
||||
return EncryptedStorage.preferences(instance, npub)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: Amethyst
|
||||
private set
|
||||
|
||||
@@ -20,32 +20,35 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
object EncryptedStorage {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
class EncryptedStorage {
|
||||
companion object {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
|
||||
// returns the preferences for each account or a global file if null.
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
// returns the preferences for each account or a global file if null.
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
|
||||
fun preferences(npub: String? = null): EncryptedSharedPreferences {
|
||||
val context = Amethyst.instance
|
||||
val masterKey: MasterKey =
|
||||
MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
fun preferences(
|
||||
applicationContext: Context,
|
||||
npub: String? = null,
|
||||
): EncryptedSharedPreferences {
|
||||
val masterKey: MasterKey =
|
||||
MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
val preferencesName = prefsFileName(npub)
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
preferencesName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
) as EncryptedSharedPreferences
|
||||
return EncryptedSharedPreferences.create(
|
||||
applicationContext,
|
||||
prefsFileName(npub),
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
) as EncryptedSharedPreferences
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.model.parseBooleanType
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.parseThemeType
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -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)}",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -32,6 +32,7 @@ fun checkForHashtagWithIcon(
|
||||
primary: Color,
|
||||
): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"₿itcoin",
|
||||
"bitcoin",
|
||||
"btc",
|
||||
"timechain",
|
||||
|
||||
@@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.events.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
|
||||
@@ -57,6 +58,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
@@ -98,6 +100,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -462,6 +465,20 @@ object LocalCache {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: CommunityListEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: ChannelListEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: FileServersEvent,
|
||||
relay: Relay?,
|
||||
@@ -1397,6 +1414,7 @@ object LocalCache {
|
||||
try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1446,6 +1464,7 @@ object LocalCache {
|
||||
try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1738,6 +1757,7 @@ object LocalCache {
|
||||
try {
|
||||
event.checkSignature()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson())
|
||||
}
|
||||
false
|
||||
@@ -1768,6 +1788,7 @@ object LocalCache {
|
||||
is CalendarTimeSlotEvent -> consume(event, relay)
|
||||
is CalendarRSVPEvent -> consume(event, relay)
|
||||
is ChannelCreateEvent -> consume(event)
|
||||
is ChannelListEvent -> consume(event, relay)
|
||||
is ChannelHideMessageEvent -> consume(event)
|
||||
is ChannelMessageEvent -> consume(event, relay)
|
||||
is ChannelMetadataEvent -> consume(event)
|
||||
@@ -1775,6 +1796,7 @@ object LocalCache {
|
||||
is ChatMessageEvent -> consume(event, relay)
|
||||
is ClassifiedsEvent -> consume(event, relay)
|
||||
is CommunityDefinitionEvent -> consume(event, relay)
|
||||
is CommunityListEvent -> consume(event, relay)
|
||||
is CommunityPostApprovalEvent -> {
|
||||
event.containedPost()?.let { verifyAndConsume(it, relay) }
|
||||
consume(event)
|
||||
@@ -1831,6 +1853,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import com.vitorpamplona.quartz.events.WrappedEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
@@ -614,7 +615,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
// Regular Zap Receipts
|
||||
zaps.forEach {
|
||||
val noteEvent = it?.value?.event
|
||||
val noteEvent = it.value?.event
|
||||
if (noteEvent is LnZapEvent) {
|
||||
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
|
||||
}
|
||||
@@ -644,6 +645,7 @@ open class Note(val idHex: String) {
|
||||
try {
|
||||
LnInvoiceUtil.getAmountInSats(invoice)
|
||||
} catch (e: java.lang.Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
@@ -694,6 +696,7 @@ open class Note(val idHex: String) {
|
||||
try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
@@ -709,6 +712,7 @@ open class Note(val idHex: String) {
|
||||
try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.quartz.encoders.Nip54
|
||||
import com.vitorpamplona.quartz.encoders.Nip92
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
@@ -42,6 +45,8 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
@@ -93,25 +98,33 @@ val HTTPRegex =
|
||||
.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
class RichTextParser() {
|
||||
fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? {
|
||||
fun parseMediaUrl(
|
||||
fullUrl: String,
|
||||
eventTags: ImmutableListOfLists<String>,
|
||||
): ZoomableUrlContent? {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
val frags = Nip44UrlParser().parse(fullUrl)
|
||||
val frags = Nip54().parse(fullUrl)
|
||||
val tags = Nip92().parse(fullUrl, eventTags.lists)
|
||||
|
||||
ZoomableUrlImage(
|
||||
url = fullUrl,
|
||||
description = frags["alt"],
|
||||
hash = frags["x"],
|
||||
blurhash = frags["blurhash"],
|
||||
dim = frags["dim"],
|
||||
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
|
||||
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
|
||||
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
|
||||
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
|
||||
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
val frags = Nip44UrlParser().parse(fullUrl)
|
||||
val frags = Nip54().parse(fullUrl)
|
||||
val tags = Nip92().parse(fullUrl, eventTags.lists)
|
||||
ZoomableUrlVideo(
|
||||
url = fullUrl,
|
||||
description = frags["alt"],
|
||||
hash = frags["x"],
|
||||
blurhash = frags["blurhash"],
|
||||
dim = frags["dim"],
|
||||
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
|
||||
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
|
||||
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
|
||||
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
|
||||
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -143,7 +156,7 @@ class RichTextParser() {
|
||||
}
|
||||
|
||||
val imagesForPager =
|
||||
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl) }.associateBy { it.url }
|
||||
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
val emojiMap =
|
||||
@@ -167,34 +180,34 @@ class RichTextParser() {
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): ImmutableList<ParagraphState> {
|
||||
var paragraphSegments = persistentListOf<ParagraphState>()
|
||||
val lines = content.split('\n')
|
||||
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
var segments = persistentListOf<Segment>()
|
||||
lines.forEach { paragraph ->
|
||||
var isDirty = false
|
||||
|
||||
val isRTL = isArabic(paragraph)
|
||||
|
||||
val wordList = paragraph.trimEnd().split(' ')
|
||||
val segments = ArrayList<Segment>(wordList.size)
|
||||
wordList.forEach { word ->
|
||||
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
|
||||
if (wordSegment !is RegularTextSegment) {
|
||||
isDirty = true
|
||||
}
|
||||
segments = segments.add(wordSegment)
|
||||
segments.add(wordSegment)
|
||||
}
|
||||
|
||||
val newSegments =
|
||||
if (isDirty) {
|
||||
ParagraphState(segments, isRTL)
|
||||
ParagraphState(segments.toPersistentList(), isRTL)
|
||||
} else {
|
||||
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
|
||||
}
|
||||
|
||||
paragraphSegments = paragraphSegments.add(newSegments)
|
||||
paragraphSegments.add(newSegments)
|
||||
}
|
||||
|
||||
return paragraphSegments
|
||||
return paragraphSegments.toImmutableList()
|
||||
}
|
||||
|
||||
fun isNumber(word: String): Boolean {
|
||||
@@ -244,7 +257,7 @@ class RichTextParser() {
|
||||
parseHash(word, tags)
|
||||
} else if (word.contains(".") && schemelessMatcher.find()) {
|
||||
val url = schemelessMatcher.group(1) // url
|
||||
val additionalChars = schemelessMatcher.group(4) // additional chars
|
||||
val additionalChars = schemelessMatcher.group(4).ifEmpty { null } // additional chars
|
||||
val pattern =
|
||||
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
|
||||
.toRegex(RegexOption.IGNORE_CASE)
|
||||
@@ -283,6 +296,7 @@ class RichTextParser() {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
}
|
||||
|
||||
@@ -293,10 +307,11 @@ class RichTextParser() {
|
||||
if (hashtagMatcher.find()) {
|
||||
val hashtag = hashtagMatcher.group(1)
|
||||
if (hashtag != null) {
|
||||
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
|
||||
return HashTagSegment(word, hashtag, hashtagMatcher.group(2).ifEmpty { null })
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Immutable
|
||||
data class CashuToken(
|
||||
@@ -59,6 +60,7 @@ class CashuProcessor {
|
||||
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
@@ -119,7 +121,7 @@ class CashuProcessor {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
@@ -154,6 +156,7 @@ class CashuProcessor {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
context.getString(R.string.cashu_successful_redemption),
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
@@ -170,7 +173,7 @@ class CashuProcessor {
|
||||
context: Context,
|
||||
) {
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
@@ -211,6 +214,7 @@ class CashuProcessor {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
context.getString(R.string.cashu_successful_redemption),
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -59,6 +60,7 @@ class FileHeader(
|
||||
onError(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError(e.message)
|
||||
}
|
||||
@@ -174,6 +176,7 @@ class FileHeader(
|
||||
|
||||
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError(e.message)
|
||||
}
|
||||
|
||||
@@ -32,14 +32,13 @@ import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClient {
|
||||
val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L)
|
||||
val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L)
|
||||
object HttpClientManager {
|
||||
val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L)
|
||||
val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L)
|
||||
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
|
||||
var defaultHttpClient: OkHttpClient? = null
|
||||
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
private var defaultHttpClient: OkHttpClient? = null
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by
|
||||
@@ -49,26 +48,34 @@ object HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
fun start(proxy: Proxy?) {
|
||||
fun setDefaultProxy(proxy: Proxy?) {
|
||||
if (internalProxy != proxy) {
|
||||
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
|
||||
this.internalProxy = proxy
|
||||
this.defaultHttpClient = getHttpClient()
|
||||
|
||||
// recreates singleton
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeTimeouts(timeout: Duration) {
|
||||
fun setDefaultTimeout(timeout: Duration) {
|
||||
Log.d("HttpClient", "Changing timeout to: $timeout")
|
||||
if (this.defaultTimeout.seconds != timeout.seconds) {
|
||||
this.defaultTimeout = timeout
|
||||
this.defaultHttpClient = getHttpClient()
|
||||
|
||||
// recreates singleton
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(timeout: Duration): OkHttpClient {
|
||||
val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds
|
||||
private fun buildHttpClient(
|
||||
proxy: Proxy?,
|
||||
timeout: Duration,
|
||||
): OkHttpClient {
|
||||
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(internalProxy)
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
@@ -91,24 +98,13 @@ object HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClientForRelays(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = getHttpClient(defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = getHttpClient(defaultTimeout)
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return internalProxy
|
||||
}
|
||||
|
||||
fun initProxy(
|
||||
useProxy: Boolean,
|
||||
hostname: String,
|
||||
@@ -29,6 +29,7 @@ import android.location.LocationManager
|
||||
import android.os.HandlerThread
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class LocationUtil(context: Context) {
|
||||
@@ -105,6 +106,7 @@ class ReverseGeoLocationUtil {
|
||||
.joinToString(", ")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
@@ -64,7 +65,7 @@ class Nip05NostrAddressVerifier() {
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
HttpClientManager.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
@@ -96,7 +97,8 @@ class Nip05NostrAddressVerifier() {
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: java.lang.Exception) {
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
}
|
||||
@@ -122,7 +124,8 @@ class Nip05NostrAddressVerifier() {
|
||||
val nip05url =
|
||||
try {
|
||||
mapper.readTree(it.lowercase())
|
||||
} catch (t: Throwable) {
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
@@ -84,7 +85,7 @@ class Nip11Retriever {
|
||||
val request: Request =
|
||||
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
HttpClientManager.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
@@ -102,6 +103,7 @@ class Nip11Retriever {
|
||||
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(
|
||||
"RelayInfoFail",
|
||||
"Resulting Message from Relay $dirtyUrl in not parseable: $body",
|
||||
@@ -122,6 +124,7 @@ class Nip11Retriever {
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
// Rename to the corect nip number when ready.
|
||||
object Nip47WalletConnectParser {
|
||||
@@ -42,6 +43,7 @@ object Nip47WalletConnectParser {
|
||||
try {
|
||||
decodePublicKey(pubkey).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Request
|
||||
|
||||
object Nip96MediaServers {
|
||||
@@ -87,7 +88,7 @@ class Nip96Retriever {
|
||||
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient().newCall(request).execute().use { response ->
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use { response ->
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
@@ -100,6 +101,7 @@ class Nip96Retriever {
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
|
||||
throw e
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ class Nip96Uploader(val account: Account?) {
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
@@ -194,7 +194,7 @@ class Nip96Uploader(val account: Account?) {
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
@@ -227,7 +227,7 @@ class Nip96Uploader(val account: Account?) {
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
): PartialEvent {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
var currentResult = result
|
||||
|
||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||
|
||||
@@ -248,19 +248,11 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.close(updatedSubscription.id)
|
||||
if (active) {
|
||||
Log.d(
|
||||
this@NostrDataSource.javaClass.simpleName,
|
||||
"Update Filter 1 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
|
||||
)
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
|
||||
}
|
||||
} else {
|
||||
// hasn't changed, does nothing.
|
||||
if (active) {
|
||||
Log.d(
|
||||
this@NostrDataSource.javaClass.simpleName,
|
||||
"Update Filter 2 ${updatedSubscription.id} ${Client.isSubscribed(clientListener)}",
|
||||
)
|
||||
Client.sendFilterOnlyIfDisconnected(
|
||||
updatedSubscription.id,
|
||||
updatedSubscriptionNewFilters,
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
private var searchString: String? = null
|
||||
@@ -67,6 +68,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
|
||||
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Request
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
|
||||
|
||||
@@ -59,13 +60,14 @@ object OnlineChecker {
|
||||
.build()
|
||||
|
||||
val result =
|
||||
HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
|
||||
object PackageUtils {
|
||||
private fun isPackageInstalled(
|
||||
@@ -36,7 +38,13 @@ object PackageUtils {
|
||||
return isPackageInstalled(context, "org.torproject.android")
|
||||
}
|
||||
|
||||
fun isAmberInstalled(context: Context): Boolean {
|
||||
return isPackageInstalled(context, "com.greenart7c3.nostrsigner")
|
||||
fun isExternalSignerInstalled(context: Context): Boolean {
|
||||
val intent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = Uri.parse("nostrsigner:")
|
||||
}
|
||||
val infos = context.packageManager.queryIntentActivities(intent, 0)
|
||||
return infos.size > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import android.content.Context
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
@@ -33,9 +33,10 @@ import okhttp3.Request
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.net.URLEncoder
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class LightningAddressResolver() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
@@ -96,14 +97,16 @@ class LightningAddressResolver() {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
|
||||
url,
|
||||
lnaddress,
|
||||
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -183,6 +186,7 @@ class LightningAddressResolver() {
|
||||
try {
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
@@ -218,6 +222,7 @@ class LightningAddressResolver() {
|
||||
try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
|
||||
@@ -25,8 +25,9 @@ import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -106,10 +107,11 @@ class RegisterAccounts(
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
|
||||
} catch (e: java.lang.Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
val tag =
|
||||
if (BuildConfig.FLAVOR == "play") {
|
||||
"FirebaseMsgService"
|
||||
|
||||
@@ -28,6 +28,7 @@ import android.util.LruCache
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
object PlaybackClientController {
|
||||
val cache = LruCache<Int, SessionToken>(1)
|
||||
@@ -62,12 +63,14 @@ object PlaybackClientController {
|
||||
try {
|
||||
onReady(controllerFuture.get())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
class PlaybackService : MediaSessionService() {
|
||||
@@ -42,12 +42,12 @@ class PlaybackService : MediaSessionService() {
|
||||
private var managerLocal: MultiPlayerPlaybackManager? = null
|
||||
|
||||
fun newHslDataSource(): MediaSource.Factory {
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
|
||||
}
|
||||
|
||||
fun newProgressiveDataSource(): MediaSource.Factory {
|
||||
return ProgressiveMediaSource.Factory(
|
||||
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient()),
|
||||
(applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class PlaybackService : MediaSessionService() {
|
||||
Log.d("Lifetime Event", "PlaybackService.onCreate")
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
HttpClientManager.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
}
|
||||
|
||||
private fun onProxyUpdated() {
|
||||
@@ -114,7 +114,7 @@ class PlaybackService : MediaSessionService() {
|
||||
override fun onDestroy() {
|
||||
Log.d("Lifetime Event", "PlaybackService.onDestroy")
|
||||
|
||||
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
|
||||
managerHls?.releaseAppPlayers()
|
||||
managerLocal?.releaseAppPlayers()
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -29,6 +30,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
try {
|
||||
fetch(timeOut)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
callback?.onFailed(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType
|
||||
@@ -90,7 +91,8 @@ suspend fun getDocument(
|
||||
): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder().url(url).get().build()
|
||||
HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
if (it.isSuccessful) {
|
||||
val mimeType =
|
||||
it.headers.get("Content-Type")?.toMediaType()
|
||||
|
||||
@@ -62,8 +62,8 @@ object Constants {
|
||||
write = true,
|
||||
feedTypes = activeTypesChats,
|
||||
),
|
||||
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
// Less Reliable
|
||||
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true,
|
||||
// feedTypes = activeTypes),
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
@@ -31,11 +31,11 @@ import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.events.bytesUsedInMemory
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.lang.StringBuilder
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
enum class FeedType {
|
||||
@@ -63,7 +63,7 @@ class Relay(
|
||||
const val RECONNECTING_IN_SECONDS = 60 * 3
|
||||
}
|
||||
|
||||
private val httpClient = HttpClient.getHttpClientForRelays()
|
||||
private val httpClient = HttpClientManager.getHttpClient()
|
||||
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
@@ -77,7 +77,7 @@ class Relay(
|
||||
var errorCounter = 0
|
||||
var pingInMs: Long? = null
|
||||
|
||||
var closingTimeInSeconds = 0L
|
||||
var lastConnectTentative: Long = 0L
|
||||
|
||||
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
|
||||
|
||||
@@ -107,7 +107,7 @@ class Relay(
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
Log.d("Relay", "Relay.connect $url")
|
||||
Log.d("Relay", "Relay.connect $url hasProxy: ${this.httpClient.proxy != null}")
|
||||
// BRB is crashing OkHttp Deflater object :(
|
||||
if (url.contains("brb.io")) return
|
||||
|
||||
@@ -120,6 +120,8 @@ class Relay(
|
||||
|
||||
if (socket != null) return
|
||||
|
||||
lastConnectTentative = TimeUtils.now()
|
||||
|
||||
try {
|
||||
val request =
|
||||
Request.Builder()
|
||||
@@ -129,6 +131,8 @@ class Relay(
|
||||
|
||||
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
errorCounter++
|
||||
markConnectionAsClosed()
|
||||
Log.e("Relay", "Relay Invalid $url")
|
||||
@@ -168,8 +172,9 @@ class Relay(
|
||||
|
||||
try {
|
||||
processNewRelayMessage(text)
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
text.chunked(2000) { chunked ->
|
||||
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
|
||||
}
|
||||
@@ -248,7 +253,6 @@ class Relay(
|
||||
this.isReady = false
|
||||
this.usingCompression = false
|
||||
this.resetEOSEStatuses()
|
||||
this.closingTimeInSeconds = TimeUtils.now()
|
||||
}
|
||||
|
||||
fun processNewRelayMessage(newMessage: String) {
|
||||
@@ -328,7 +332,7 @@ class Relay(
|
||||
Log.d("Relay", "Relay.disconnect $url")
|
||||
checkNotInMainThread()
|
||||
|
||||
closingTimeInSeconds = TimeUtils.now()
|
||||
lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested.
|
||||
socket?.cancel()
|
||||
socket = null
|
||||
isReady = false
|
||||
@@ -370,7 +374,7 @@ class Relay(
|
||||
}
|
||||
} else {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
|
||||
if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) {
|
||||
// sends all filters after connection is successful.
|
||||
connect()
|
||||
}
|
||||
@@ -410,7 +414,7 @@ class Relay(
|
||||
|
||||
if (socket == null) {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
|
||||
if (TimeUtils.now() > lastConnectTentative + RECONNECTING_IN_SECONDS) {
|
||||
// println("sendfilter Only if Disconnected ${url} ")
|
||||
connect()
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
|
||||
@@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -112,7 +113,9 @@ class MainActivity : AppCompatActivity() {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
accountStateViewModel.serviceManager = serviceManager
|
||||
|
||||
LaunchedEffect(key1 = Unit) { accountStateViewModel.tryLoginExistingAccountAsync() }
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
@@ -157,7 +160,9 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onPause() {
|
||||
Log.d("Lifetime Event", "MainActivity.onPause")
|
||||
|
||||
LanguageTranslatorService.clear()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LanguageTranslatorService.clear()
|
||||
}
|
||||
serviceManager.cleanObservers()
|
||||
|
||||
// if (BuildConfig.DEBUG) {
|
||||
@@ -236,9 +241,9 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (changedNetwork) {
|
||||
if (isOnMobileData) {
|
||||
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE)
|
||||
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_MOBILE)
|
||||
} else {
|
||||
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI)
|
||||
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_WIFI)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +346,7 @@ fun uriToRoute(uri: String?): String? {
|
||||
Route.Home.base + "?nip47=" + encodedUri
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
@@ -57,6 +58,7 @@ class ImageDownloader {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
tentatives++
|
||||
delay(1000)
|
||||
null
|
||||
|
||||
@@ -30,7 +30,8 @@ import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
@@ -55,7 +56,7 @@ object ImageSaver {
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
@@ -102,6 +103,7 @@ object ImageSaver {
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
@@ -138,6 +140,7 @@ object ImageSaver {
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
@@ -176,6 +179,7 @@ object ImageSaver {
|
||||
|
||||
outputStream.use { contentSource.readAll(it.sink()) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
contentResolver.delete(uri, null, null)
|
||||
throw e
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -148,6 +149,7 @@ open class NewMediaModel : ViewModel() {
|
||||
context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -231,6 +232,7 @@ fun ImageVideoPost(
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
@@ -279,6 +281,7 @@ fun ImageVideoPost(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
SettingSwitchItem(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
checked = postViewModel.sensitiveContent,
|
||||
onCheckedChange = { postViewModel.sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class NewMessageTagger(
|
||||
var message: String,
|
||||
@@ -196,6 +197,7 @@ class NewMessageTagger(
|
||||
) // no way to know when they address ends and dirt begins
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@Composable
|
||||
fun NewPollClosing(pollViewModel: NewPostViewModel) {
|
||||
@@ -58,6 +59,7 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) {
|
||||
pollViewModel.closedAt = int
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
pollViewModel.isValidClosedAt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@Composable
|
||||
fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
|
||||
@@ -58,6 +59,7 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
|
||||
pollViewModel.consensusThreshold = int
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
pollViewModel.isValidConsensusThreshold.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -49,8 +50,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -98,7 +97,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -157,9 +155,9 @@ import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
@@ -179,6 +177,7 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -262,7 +261,7 @@ fun NewPostView(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
contentDescription = null,
|
||||
contentDescription = stringResource(id = R.string.relay_list_selector),
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
@@ -303,25 +302,38 @@ fun NewPostView(
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
start = Size10dp,
|
||||
top = pad.calculateTopPadding(),
|
||||
end = Size10dp,
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
)
|
||||
Modifier
|
||||
.padding(
|
||||
start = Size10dp,
|
||||
top = pad.calculateTopPadding(),
|
||||
end = Size10dp,
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.imePadding().weight(1f),
|
||||
modifier =
|
||||
Modifier
|
||||
.imePadding()
|
||||
.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
postViewModel.originalNote?.let {
|
||||
Row(Modifier.heightIn(max = 200.dp)) {
|
||||
@@ -473,7 +485,8 @@ fun NewPostView(
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier.padding(top = 4.dp)
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
@@ -510,79 +523,13 @@ fun NewPostView(
|
||||
}
|
||||
}
|
||||
|
||||
val userSuggestions = postViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
top = 10.dp,
|
||||
),
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex },
|
||||
) { _, item ->
|
||||
UserLine(item, accountViewModel) { postViewModel.autocompleteWithUser(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
ShowUserSuggestionList(
|
||||
postViewModel,
|
||||
accountViewModel,
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
// These should be hashtag recommendations the user selects in the future.
|
||||
// val hashtag = stringResource(R.string.poll_hashtag)
|
||||
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
if (postViewModel.wantsPoll) {
|
||||
postViewModel.wantsProduct = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddClassifiedsButton(postViewModel) {
|
||||
postViewModel.wantsProduct = !postViewModel.wantsProduct
|
||||
if (postViewModel.wantsProduct) {
|
||||
postViewModel.wantsPoll = false
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddInvoice) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddZapRaiser) {
|
||||
AddZapraiserButton(postViewModel.wantsZapraiser) {
|
||||
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
|
||||
}
|
||||
}
|
||||
|
||||
MarkAsSensitive(postViewModel) {
|
||||
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
|
||||
}
|
||||
|
||||
AddGeoHash(postViewModel) {
|
||||
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
||||
}
|
||||
|
||||
ForwardZapTo(postViewModel) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
}
|
||||
}
|
||||
BottomRowActions(postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,6 +537,71 @@ fun NewPostView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomRowActions(postViewModel: NewPostViewModel) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(scrollState)
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
// These should be hashtag recommendations the user selects in the future.
|
||||
// val hashtag = stringResource(R.string.poll_hashtag)
|
||||
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
if (postViewModel.wantsPoll) {
|
||||
postViewModel.wantsProduct = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddClassifiedsButton(postViewModel) {
|
||||
postViewModel.wantsProduct = !postViewModel.wantsProduct
|
||||
if (postViewModel.wantsProduct) {
|
||||
postViewModel.wantsPoll = false
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddInvoice) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddZapRaiser) {
|
||||
AddZapraiserButton(postViewModel.wantsZapraiser) {
|
||||
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
|
||||
}
|
||||
}
|
||||
|
||||
MarkAsSensitive(postViewModel) {
|
||||
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
|
||||
}
|
||||
|
||||
AddGeoHash(postViewModel) {
|
||||
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
||||
}
|
||||
|
||||
ForwardZapTo(postViewModel) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollField(postViewModel: NewPostViewModel) {
|
||||
Column(
|
||||
@@ -622,7 +634,6 @@ private fun PollField(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MessageField(postViewModel: NewPostViewModel) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -643,7 +654,8 @@ private fun MessageField(postViewModel: NewPostViewModel) {
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
@@ -683,21 +695,32 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.height(20.dp).width(25.dp),
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VisibilityOff,
|
||||
contentDescription = stringResource(id = R.string.content_warning),
|
||||
modifier = Modifier.size(18.dp).align(Alignment.BottomStart),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.BottomStart),
|
||||
tint = Color.Red,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Warning,
|
||||
contentDescription = stringResource(id = R.string.content_warning),
|
||||
modifier = Modifier.size(10.dp).align(Alignment.TopEnd),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Yellow,
|
||||
)
|
||||
}
|
||||
@@ -920,7 +943,10 @@ fun SellProduct(postViewModel: NewPostViewModel) {
|
||||
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
|
||||
options = conditionOptions,
|
||||
onSelect = { postViewModel.condition = conditionTypes[it].first },
|
||||
modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp, bottom = 1.dp),
|
||||
) { currentOption, modifier ->
|
||||
MyTextField(
|
||||
value = TextFieldValue(currentOption),
|
||||
@@ -981,7 +1007,10 @@ fun SellProduct(postViewModel: NewPostViewModel) {
|
||||
?: "",
|
||||
options = categoryOptions,
|
||||
onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) },
|
||||
modifier = Modifier.weight(1f).padding(end = 5.dp, bottom = 1.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp, bottom = 1.dp),
|
||||
) { currentOption, modifier ->
|
||||
MyTextField(
|
||||
value = TextFieldValue(currentOption),
|
||||
@@ -1046,21 +1075,32 @@ fun FowardZapTo(
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.height(20.dp).width(25.dp),
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier.size(20.dp).align(Alignment.CenterStart),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowForwardIos,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier.size(13.dp).align(Alignment.CenterEnd),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
}
|
||||
@@ -1160,10 +1200,15 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.height(20.dp).width(20.dp),
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(20.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
@@ -1304,32 +1349,46 @@ private fun AddZapraiserButton(
|
||||
onClick = { onClick() },
|
||||
) {
|
||||
Box(
|
||||
Modifier.height(20.dp).width(25.dp),
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp),
|
||||
) {
|
||||
if (!isLnInvoiceActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ShowChart,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopStart),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(13.dp).align(Alignment.BottomEnd),
|
||||
contentDescription = stringResource(R.string.add_zapraiser),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ShowChart,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopStart),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(13.dp).align(Alignment.BottomEnd),
|
||||
contentDescription = stringResource(R.string.cancel_zapraiser),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
}
|
||||
@@ -1348,14 +1407,14 @@ fun AddGeoHash(
|
||||
if (!postViewModel.wantsToAddGeoHash) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOff,
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.add_location),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.remove_location),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -1374,14 +1433,14 @@ private fun AddLnInvoiceButton(
|
||||
if (!isLnInvoiceActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.add_bitcoin_invoice),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
@@ -1398,32 +1457,46 @@ private fun ForwardZapTo(
|
||||
onClick = { onClick() },
|
||||
) {
|
||||
Box(
|
||||
Modifier.height(20.dp).width(25.dp),
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp),
|
||||
) {
|
||||
if (!postViewModel.wantsForwardZapTo) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(20.dp).align(Alignment.CenterStart),
|
||||
contentDescription = stringResource(R.string.add_zap_split),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowForwardIos,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(13.dp).align(Alignment.CenterEnd),
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier.size(20.dp).align(Alignment.CenterStart),
|
||||
contentDescription = stringResource(id = R.string.cancel_zap_split),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowForwardIos,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier.size(13.dp).align(Alignment.CenterEnd),
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
}
|
||||
@@ -1449,7 +1522,7 @@ private fun AddClassifiedsButton(
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Sell,
|
||||
contentDescription = stringResource(id = R.string.classifieds),
|
||||
contentDescription = stringResource(id = R.string.cancel_classifieds),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
@@ -1466,32 +1539,46 @@ private fun MarkAsSensitive(
|
||||
onClick = { onClick() },
|
||||
) {
|
||||
Box(
|
||||
Modifier.height(20.dp).width(23.dp),
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(23.dp),
|
||||
) {
|
||||
if (!postViewModel.wantsToMarkAsSensitive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
contentDescription = stringResource(R.string.content_warning),
|
||||
modifier = Modifier.size(18.dp).align(Alignment.BottomStart),
|
||||
contentDescription = stringResource(R.string.add_content_warning),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.BottomStart),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Warning,
|
||||
contentDescription = stringResource(R.string.content_warning),
|
||||
modifier = Modifier.size(10.dp).align(Alignment.TopEnd),
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VisibilityOff,
|
||||
contentDescription = stringResource(id = R.string.content_warning),
|
||||
modifier = Modifier.size(18.dp).align(Alignment.BottomStart),
|
||||
contentDescription = stringResource(id = R.string.remove_content_warning),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.BottomStart),
|
||||
tint = Color.Red,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Warning,
|
||||
contentDescription = stringResource(id = R.string.content_warning),
|
||||
modifier = Modifier.size(10.dp).align(Alignment.TopEnd),
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Yellow,
|
||||
)
|
||||
}
|
||||
@@ -1600,7 +1687,8 @@ fun ImageVideoDescription(
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
@@ -1610,11 +1698,17 @@ fun ImageVideoDescription(
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(30.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
@@ -1632,13 +1726,17 @@ fun ImageVideoDescription(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier =
|
||||
Modifier.padding(start = 10.dp)
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1.0f)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp).padding(end = 5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(30.dp)
|
||||
.padding(end = 5.dp),
|
||||
onClick = onCancel,
|
||||
) {
|
||||
CancelIcon()
|
||||
@@ -1650,7 +1748,8 @@ fun ImageVideoDescription(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
) {
|
||||
@@ -1660,7 +1759,8 @@ fun ImageVideoDescription(
|
||||
contentDescription = uri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier.padding(top = 4.dp)
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
)
|
||||
@@ -1674,6 +1774,7 @@ fun ImageVideoDescription(
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Unable to load thumbnail")
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
@@ -1685,7 +1786,10 @@ fun ImageVideoDescription(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.padding(top = 4.dp).fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -1707,7 +1811,10 @@ fun ImageVideoDescription(
|
||||
?: fileServers[0].server.name,
|
||||
options = fileServerOptions,
|
||||
onSelect = { selectedServer = fileServers[it] },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1716,6 +1823,10 @@ fun ImageVideoDescription(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
SettingSwitchItem(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
checked = sensitiveContent,
|
||||
onCheckedChange = { sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
@@ -1726,12 +1837,16 @@ fun ImageVideoDescription(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
@@ -1748,7 +1863,10 @@ fun ImageVideoDescription(
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = { onAdd(message, selectedServer, sensitiveContent) },
|
||||
shape = QuoteBorder,
|
||||
colors =
|
||||
@@ -1764,7 +1882,10 @@ fun ImageVideoDescription(
|
||||
|
||||
@Composable
|
||||
fun SettingSwitchItem(
|
||||
modifier: Modifier = Modifier,
|
||||
modifier: Modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
title: Int,
|
||||
@@ -1774,8 +1895,6 @@ fun SettingSwitchItem(
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.toggleable(
|
||||
value = checked,
|
||||
enabled = enabled,
|
||||
@@ -1786,7 +1905,7 @@ fun SettingSwitchItem(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
|
||||
@@ -63,6 +63,7 @@ import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.events.findURLs
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
@@ -70,7 +71,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLEncoder
|
||||
|
||||
enum class UserSuggestionAnchor {
|
||||
MAIN_MESSAGE,
|
||||
@@ -88,6 +88,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
var pTags by mutableStateOf<List<User>?>(null)
|
||||
var eTags by mutableStateOf<List<Note>?>(null)
|
||||
var imetaTags = mutableStateListOf<Array<String>>()
|
||||
|
||||
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
|
||||
var nip95attachments by
|
||||
@@ -264,45 +265,47 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = nip94attachments.filter { it.urls().intersect(urls).isNotEmpty() }
|
||||
usedAttachments.forEach { account?.sendHeader(it, relayList, {}) }
|
||||
val usedAttachments = nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
|
||||
// Doesn't send as nip94 yet because we don't know if it makes sense.
|
||||
// usedAttachments.forEach { account?.sendHeader(it, relayList, {}) }
|
||||
|
||||
if (originalNote?.channelHex() != null) {
|
||||
if (originalNote is AddressableEvent && originalNote?.address() != null) {
|
||||
account?.sendLiveMessage(
|
||||
tagger.message,
|
||||
originalNote?.address()!!,
|
||||
tagger.eTags,
|
||||
tagger.pTags,
|
||||
zapReceiver,
|
||||
wantsToMarkAsSensitive,
|
||||
localZapRaiserAmount,
|
||||
geoHash,
|
||||
message = tagger.message,
|
||||
toChannel = originalNote?.address()!!,
|
||||
replyTo = tagger.eTags,
|
||||
mentions = tagger.pTags,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
)
|
||||
} else {
|
||||
account?.sendChannelMessage(
|
||||
tagger.message,
|
||||
tagger.channelHex!!,
|
||||
tagger.eTags,
|
||||
tagger.pTags,
|
||||
zapReceiver,
|
||||
wantsToMarkAsSensitive,
|
||||
localZapRaiserAmount,
|
||||
geoHash,
|
||||
message = tagger.message,
|
||||
toChannel = tagger.channelHex!!,
|
||||
replyTo = tagger.eTags,
|
||||
mentions = tagger.pTags,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
)
|
||||
}
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(
|
||||
tagger.message,
|
||||
originalNote!!.author!!,
|
||||
originalNote!!,
|
||||
tagger.pTags,
|
||||
zapReceiver,
|
||||
wantsToMarkAsSensitive,
|
||||
localZapRaiserAmount,
|
||||
geoHash,
|
||||
message = tagger.message,
|
||||
toUser = originalNote!!.author!!,
|
||||
replyingTo = originalNote!!,
|
||||
mentions = tagger.pTags,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
)
|
||||
} else if (originalNote?.event is ChatMessageEvent) {
|
||||
val receivers =
|
||||
@@ -323,6 +326,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
zapReceiver = zapReceiver,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
)
|
||||
} else if (!dmUsers.isNullOrEmpty()) {
|
||||
if (nip24 || dmUsers.size > 1) {
|
||||
@@ -336,6 +340,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
zapReceiver = zapReceiver,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
)
|
||||
} else {
|
||||
account?.sendPrivateMessage(
|
||||
@@ -347,6 +352,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
zapReceiver = zapReceiver,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -460,22 +466,14 @@ open class NewPostViewModel() : ViewModel() {
|
||||
onProgress = {},
|
||||
)
|
||||
|
||||
if (!isPrivate) {
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
)
|
||||
} else {
|
||||
noNIP94(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
)
|
||||
}
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
@@ -506,6 +504,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
pTags = null
|
||||
imetaTags.clear()
|
||||
|
||||
wantsDirectMessage = false
|
||||
|
||||
@@ -701,21 +700,6 @@ open class NewPostViewModel() : ViewModel() {
|
||||
contentToAddUrl == null
|
||||
}
|
||||
|
||||
fun includePollHashtagInMessage(
|
||||
include: Boolean,
|
||||
hashtag: String,
|
||||
) {
|
||||
if (include) {
|
||||
updateMessage(TextFieldValue(message.text + " $hashtag"))
|
||||
} else {
|
||||
updateMessage(
|
||||
TextFieldValue(
|
||||
message.text.replace(" $hashtag", "").replace(hashtag, ""),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
@@ -749,25 +733,13 @@ open class NewPostViewModel() : ViewModel() {
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) {
|
||||
event,
|
||||
->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
nip94attachments = nip94attachments + event
|
||||
val contentWarning = if (sensitiveContent) "" else null
|
||||
|
||||
message =
|
||||
TextFieldValue(
|
||||
message.text +
|
||||
"\n" +
|
||||
addInlineMetadataAsNIP54(
|
||||
imageUrl,
|
||||
header.dim,
|
||||
header.mimeType,
|
||||
alt,
|
||||
header.blurHash,
|
||||
header.hash,
|
||||
contentWarning,
|
||||
),
|
||||
message.text + "\n" + imageUrl,
|
||||
)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
@@ -779,86 +751,6 @@ open class NewPostViewModel() : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun noNIP94(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType =
|
||||
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
|
||||
val dim =
|
||||
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Server failed to return a url") }
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = { header: FileHeader ->
|
||||
isUploadingImage = false
|
||||
val contentWarning = if (sensitiveContent) "" else null
|
||||
message =
|
||||
TextFieldValue(
|
||||
message.text +
|
||||
"\n" +
|
||||
addInlineMetadataAsNIP54(
|
||||
imageUrl,
|
||||
header.dim,
|
||||
header.mimeType,
|
||||
alt,
|
||||
header.blurHash,
|
||||
header.hash,
|
||||
contentWarning,
|
||||
),
|
||||
)
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun addInlineMetadataAsNIP54(
|
||||
imageUrl: String,
|
||||
dim: String?,
|
||||
m: String?,
|
||||
alt: String?,
|
||||
blurHash: String?,
|
||||
x: String?,
|
||||
sensitiveContent: String?,
|
||||
): String {
|
||||
val extension =
|
||||
listOfNotNull(
|
||||
m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" },
|
||||
dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" },
|
||||
alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" },
|
||||
blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" },
|
||||
x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" },
|
||||
sensitiveContent
|
||||
?.ifBlank { null }
|
||||
?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" },
|
||||
)
|
||||
.joinToString("&")
|
||||
|
||||
return if (imageUrl.contains("#")) {
|
||||
"$imageUrl&$extension"
|
||||
} else {
|
||||
"$imageUrl#$extension"
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
@@ -944,6 +836,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
valueMinimum = int
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
} else {
|
||||
valueMinimum = null
|
||||
@@ -962,6 +855,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
valueMaximum = int
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
} else {
|
||||
valueMaximum = null
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class RangesChanges(val original: TextRange, val modified: TextRange)
|
||||
@@ -55,11 +56,9 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
|
||||
append(
|
||||
text
|
||||
.split('\n')
|
||||
.map { paragraph: String ->
|
||||
.split('\n').joinToString("\n") { paragraph: String ->
|
||||
paragraph
|
||||
.split(' ')
|
||||
.map { word: String ->
|
||||
.split(' ').joinToString(" ") { word: String ->
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
@@ -113,15 +112,14 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
word
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// if it can't parse the key, don't try to change.
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
}
|
||||
.joinToString(" ")
|
||||
}
|
||||
.joinToString("\n"),
|
||||
},
|
||||
)
|
||||
|
||||
substitutions.forEach {
|
||||
@@ -141,9 +139,7 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
object : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
val inInsideRange =
|
||||
substitutions
|
||||
.filter { offset > it.original.start && offset < it.original.end }
|
||||
.firstOrNull()
|
||||
substitutions.firstOrNull { offset > it.original.start && offset < it.original.end }
|
||||
|
||||
if (inInsideRange != null) {
|
||||
val percentInRange =
|
||||
@@ -163,9 +159,7 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
val inInsideRange =
|
||||
substitutions
|
||||
.filter { offset > it.modified.start && offset < it.modified.end }
|
||||
.firstOrNull()
|
||||
substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end }
|
||||
|
||||
if (inInsideRange != null) {
|
||||
val percentInRange =
|
||||
|
||||
@@ -123,7 +123,7 @@ fun ChannelFabColumn(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = stringResource(R.string.messages_create_public_chat),
|
||||
contentDescription = stringResource(R.string.messages_create_public_private_chat_desription),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -72,7 +73,7 @@ fun NewCommunityNoteButton(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.new_community_note),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -135,7 +136,7 @@ fun NewImageButton(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.new_short),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
@@ -153,7 +154,10 @@ private fun ShowProgress(postViewModel: NewMediaModel) {
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
)
|
||||
.value,
|
||||
modifier = Size55Modifier.clip(CircleShape).background(MaterialTheme.colorScheme.background),
|
||||
modifier =
|
||||
Size55Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
strokeWidth = 5.dp,
|
||||
)
|
||||
postViewModel.uploadingDescription.value?.let {
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
@@ -58,7 +59,7 @@ fun NewNoteButton(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
null,
|
||||
contentDescription = stringResource(R.string.new_post),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
|
||||
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -238,6 +239,7 @@ fun CashuPreview(
|
||||
|
||||
startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
|
||||
}
|
||||
},
|
||||
@@ -354,6 +356,7 @@ fun CashuPreviewNew(
|
||||
|
||||
startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@Composable
|
||||
fun ClickableEmail(email: String) {
|
||||
@@ -58,6 +59,7 @@ fun Context.sendMail(
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// TODO: Handle case where no email app is available
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
// TODO: Handle potential other type of exceptions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
@@ -280,12 +279,10 @@ private fun RenderUserAsClickableText(
|
||||
derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
|
||||
}
|
||||
|
||||
val addedCharts = remember(nip19) { "${nip19.additionalChars}" }
|
||||
|
||||
userDisplayName?.let {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = it,
|
||||
suffix = addedCharts,
|
||||
suffix = nip19.additionalChars,
|
||||
maxLines = 1,
|
||||
route = route,
|
||||
nav = nav,
|
||||
@@ -300,27 +297,24 @@ fun CreateClickableText(
|
||||
suffix: String?,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
fontWeight: FontWeight? = null,
|
||||
route: String,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val currentStyle = LocalTextStyle.current
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val onBackgroundColor = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
val clickablePartStyle =
|
||||
remember(primaryColor, overrideColor) {
|
||||
currentStyle
|
||||
.copy(color = overrideColor ?: primaryColor, fontWeight = fontWeight)
|
||||
.toSpanStyle()
|
||||
}
|
||||
SpanStyle(
|
||||
color = overrideColor ?: primaryColor,
|
||||
fontWeight = fontWeight,
|
||||
)
|
||||
|
||||
val nonClickablePartStyle =
|
||||
remember(onBackgroundColor, overrideColor) {
|
||||
currentStyle
|
||||
.copy(color = overrideColor ?: onBackgroundColor, fontWeight = fontWeight)
|
||||
.toSpanStyle()
|
||||
}
|
||||
SpanStyle(
|
||||
color = overrideColor ?: onBackgroundColor,
|
||||
fontWeight = fontWeight,
|
||||
)
|
||||
|
||||
val text =
|
||||
remember(clickablePartStyle, nonClickablePartStyle, clickablePart, suffix) {
|
||||
@@ -339,6 +333,41 @@ fun CreateClickableText(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableText(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
onClick: (Int) -> Unit,
|
||||
) {
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val pressIndicator =
|
||||
Modifier.pointerInput(onClick) {
|
||||
detectTapGestures { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
onClick(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier.then(pressIndicator),
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = {
|
||||
layoutResult.value = it
|
||||
onTextLayout(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
@@ -388,7 +417,7 @@ fun CreateTextWithEmoji(
|
||||
.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
textAlign = TextAlign.Unspecified,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
),
|
||||
@@ -446,7 +475,7 @@ fun CreateTextWithEmoji(
|
||||
.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
textAlign = TextAlign.Unspecified,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
),
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -44,6 +43,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ExpandableTextParser
|
||||
import com.vitorpamplona.amethyst.ui.note.getGradient
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
@@ -51,9 +51,6 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
const val SHORT_TEXT_LENGTH = 350
|
||||
const val SHORTEN_AFTER_LINES = 10
|
||||
|
||||
@Composable
|
||||
fun ExpandableRichTextViewer(
|
||||
content: String,
|
||||
@@ -66,30 +63,7 @@ fun ExpandableRichTextViewer(
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
val whereToCut =
|
||||
remember(content) {
|
||||
// Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters
|
||||
val firstSpaceAfterCut =
|
||||
content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val firstNewLineAfterCut =
|
||||
content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
|
||||
// or after SHORTEN_AFTER_LINES lines
|
||||
val numberOfLines = content.count { it == '\n' }
|
||||
|
||||
var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
|
||||
|
||||
if (numberOfLines > SHORTEN_AFTER_LINES) {
|
||||
val shortContent = content.lines().take(SHORTEN_AFTER_LINES)
|
||||
charactersInLines = 0
|
||||
for (line in shortContent) {
|
||||
// +1 because new line character is omitted from .lines
|
||||
charactersInLines += (line.length + 1)
|
||||
}
|
||||
}
|
||||
|
||||
minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines)
|
||||
}
|
||||
val whereToCut = remember(content) { ExpandableTextParser.computeWhereToCutIfPostIsTooLong(content) }
|
||||
|
||||
val text by
|
||||
remember(content) {
|
||||
@@ -103,17 +77,15 @@ fun ExpandableRichTextViewer(
|
||||
}
|
||||
|
||||
Box {
|
||||
Crossfade(text, label = "ExpandableRichTextViewer") {
|
||||
RichTextViewer(
|
||||
it,
|
||||
canPreview,
|
||||
modifier.align(Alignment.TopStart),
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
RichTextViewer(
|
||||
text,
|
||||
canPreview,
|
||||
modifier.align(Alignment.TopStart),
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
if (content.length > whereToCut && !showFullText) {
|
||||
Row(
|
||||
|
||||
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.NumberFormat
|
||||
@@ -79,6 +80,7 @@ fun LoadValueFromInvoice(
|
||||
try {
|
||||
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class MarkdownParser {
|
||||
private fun getDisplayNameAndNIP19FromTag(
|
||||
@@ -39,6 +40,7 @@ class MarkdownParser {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("Tag Parser", "Couldn't link tag $tag", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
@@ -161,6 +163,7 @@ class MarkdownParser {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.abedelazizshe.lightcompressorlibrary.config.Configuration
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import id.zelory.compressor.Compressor
|
||||
import id.zelory.compressor.constraint.default
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
@@ -115,6 +116,7 @@ class MediaCompressor {
|
||||
}
|
||||
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
onReady(uri, contentType, null)
|
||||
}
|
||||
|
||||
@@ -28,11 +28,10 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
@@ -49,7 +48,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFontFamilyResolver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
@@ -62,8 +60,10 @@ import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
@@ -100,9 +100,9 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -174,7 +174,73 @@ fun RichTextViewer(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Preview()
|
||||
@Composable
|
||||
fun RenderRegularPreview() {
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
val backgroundColorState =
|
||||
remember {
|
||||
mutableStateOf(backgroundColor)
|
||||
}
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
RenderRegular(
|
||||
"nostr:npub1e0z776cpe0gllgktjk54fuzv8pdfxmq6smsmh8xd7t8s7n474n9smk0txy but i'm Monthly funding" +
|
||||
" 7 other humans vitor@vitorpamplona.com at the moment so spread #test a bit thin, but won't always be the case.",
|
||||
EmptyTagList,
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
// is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
|
||||
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
|
||||
// is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
|
||||
is EmailSegment -> ClickableEmail(word.segmentText)
|
||||
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||
is BechSegment -> {
|
||||
CreateClickableText(
|
||||
word.segmentText.substring(0, 10),
|
||||
"",
|
||||
1,
|
||||
route = "",
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
|
||||
RenderRegular(
|
||||
"#Amethyst v0.84.1: ncryptsec support (NIP-49)",
|
||||
EmptyTagList,
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
// is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
|
||||
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
|
||||
// is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
|
||||
is EmailSegment -> ClickableEmail(word.segmentText)
|
||||
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRegular(
|
||||
content: String,
|
||||
@@ -184,75 +250,65 @@ private fun RenderRegular(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val state by remember(content) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) }
|
||||
RenderRegular(content, tags) { word, state ->
|
||||
if (canPreview) {
|
||||
RenderWordWithPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else {
|
||||
RenderWordWithoutPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RenderRegular(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
wordRenderer: @Composable (Segment, RichTextViewerState) -> Unit,
|
||||
) {
|
||||
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags)) }
|
||||
|
||||
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
|
||||
|
||||
val currentTextStyle = LocalTextStyle.current
|
||||
val currentTextColor = LocalContentColor.current
|
||||
|
||||
val textStyle =
|
||||
remember(currentTextStyle, currentTextColor) {
|
||||
remember(currentTextStyle) {
|
||||
currentTextStyle.copy(
|
||||
lineHeight = 1.4.em,
|
||||
color = currentTextStyle.color.takeOrElse { currentTextColor },
|
||||
)
|
||||
}
|
||||
|
||||
val spaceWidth = measureSpaceWidth(textStyle)
|
||||
|
||||
Column {
|
||||
if (canPreview) {
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
val direction =
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides
|
||||
if (paragraph.isRTL) {
|
||||
LayoutDirection.Rtl
|
||||
} else {
|
||||
LayoutDirection.Ltr
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
FlowRow(
|
||||
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
|
||||
) {
|
||||
paragraph.words.forEach { word ->
|
||||
RenderWordWithPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
textStyle,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
val direction =
|
||||
if (paragraph.isRTL) {
|
||||
LayoutDirection.Rtl
|
||||
} else {
|
||||
LayoutDirection.Ltr
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
|
||||
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
|
||||
) {
|
||||
paragraph.words.forEach { word ->
|
||||
RenderWordWithoutPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
textStyle,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
},
|
||||
LocalTextStyle provides textStyle,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
|
||||
) {
|
||||
paragraph.words.forEach { word ->
|
||||
wordRenderer(word, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,7 +337,6 @@ private fun RenderWordWithoutPreview(
|
||||
word: Segment,
|
||||
state: RichTextViewerState,
|
||||
backgroundColor: MutableState<Color>,
|
||||
style: TextStyle,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -291,10 +346,10 @@ private fun RenderWordWithoutPreview(
|
||||
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
// Don't offer to pay invoices
|
||||
is InvoiceSegment -> NormalWord(word.segmentText, style)
|
||||
is InvoiceSegment -> Text(word.segmentText)
|
||||
// Don't offer to withdraw
|
||||
is WithdrawSegment -> NormalWord(word.segmentText, style)
|
||||
is CashuSegment -> NormalWord(word.segmentText, style)
|
||||
is WithdrawSegment -> Text(word.segmentText)
|
||||
is CashuSegment -> Text(word.segmentText)
|
||||
is EmailSegment -> ClickableEmail(word.segmentText)
|
||||
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
|
||||
@@ -302,7 +357,7 @@ private fun RenderWordWithoutPreview(
|
||||
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||
is RegularTextSegment -> NormalWord(word.segmentText, style)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +366,6 @@ private fun RenderWordWithPreview(
|
||||
word: Segment,
|
||||
state: RichTextViewerState,
|
||||
backgroundColor: MutableState<Color>,
|
||||
style: TextStyle,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -329,7 +383,7 @@ private fun RenderWordWithPreview(
|
||||
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||
is RegularTextSegment -> NormalWord(word.segmentText, style)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,23 +401,7 @@ private fun ZoomableContentView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NormalWord(
|
||||
word: String,
|
||||
style: TextStyle,
|
||||
) {
|
||||
BasicText(
|
||||
text = word,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoProtocolUrlRenderer(word: SchemelessUrlSegment) {
|
||||
RenderUrl(word)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderUrl(segment: SchemelessUrlSegment) {
|
||||
private fun NoProtocolUrlRenderer(segment: SchemelessUrlSegment) {
|
||||
ClickableUrl(segment.url, "https://${segment.url}")
|
||||
segment.extras?.let { it1 -> Text(it1) }
|
||||
}
|
||||
@@ -416,8 +454,8 @@ private fun RenderContentAsMarkdown(
|
||||
onMediaCompose = { title, destination ->
|
||||
ZoomableContentView(
|
||||
content =
|
||||
remember(destination) {
|
||||
RichTextParser().parseMediaUrl(destination) ?: ZoomableUrlImage(url = destination)
|
||||
remember(destination, tags) {
|
||||
RichTextParser().parseMediaUrl(destination, tags ?: EmptyTagList) ?: ZoomableUrlImage(url = destination)
|
||||
},
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -580,7 +618,7 @@ fun BechLink(
|
||||
)
|
||||
}
|
||||
} else if (loadedLink?.nip19 != null) {
|
||||
Row { ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav) }
|
||||
ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav)
|
||||
} else {
|
||||
val text =
|
||||
remember(word) {
|
||||
@@ -606,7 +644,7 @@ private fun DisplayFullNote(
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
modifier = MaterialTheme.colorScheme.innerPostModifier,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
nav = nav,
|
||||
@@ -623,14 +661,6 @@ private fun DisplayFullNote(
|
||||
|
||||
@Composable
|
||||
fun HashTag(
|
||||
word: HashTagSegment,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
RenderHashtag(word, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderHashtag(
|
||||
segment: HashTagSegment,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -657,7 +687,7 @@ private fun RenderHashtag(
|
||||
}
|
||||
}
|
||||
|
||||
segment.extras?.ifBlank { "" }?.let { withStyle(regularText) { append(it) } }
|
||||
segment.extras?.let { withStyle(regularText) { append(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ fun RobohashFallbackAsyncImage(
|
||||
|
||||
Image(
|
||||
painter = base64Painter,
|
||||
contentDescription = null,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
|
||||
@@ -74,8 +74,17 @@ fun SensitivityWarning(
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val hasSensitiveContent = remember(event) { event.isSensitive() ?: false }
|
||||
val hasSensitiveContent = remember(event) { event.isSensitive() }
|
||||
|
||||
SensitivityWarning(hasSensitiveContent, accountViewModel, content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SensitivityWarning(
|
||||
hasSensitiveContent: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (hasSensitiveContent) {
|
||||
SensitivityWarning(accountViewModel, content)
|
||||
} else {
|
||||
|
||||
@@ -20,18 +20,26 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -41,16 +49,59 @@ import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
@Composable
|
||||
private fun CopyToClipboard(
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
content: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded.value,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_url_to_clipboard)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(content))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
url: String,
|
||||
previewInfo: UrlInfoItem,
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val popupExpanded =
|
||||
remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (popupExpanded.value) {
|
||||
CopyToClipboard(
|
||||
popupExpanded = popupExpanded,
|
||||
content = url,
|
||||
) {
|
||||
popupExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier.clickable { runCatching { uri.openUri(url) } },
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
runCatching { uri.openUri(url) }
|
||||
},
|
||||
onLongClick = {
|
||||
popupExpanded.value = true
|
||||
},
|
||||
),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = previewInfo.imageUrlFullPath,
|
||||
|
||||
@@ -106,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -356,6 +357,7 @@ fun GetMediaItem(
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
@@ -131,6 +131,7 @@ import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -161,6 +162,7 @@ class ZoomableUrlImage(
|
||||
val blurhash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
@@ -173,6 +175,7 @@ class ZoomableUrlVideo(
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null,
|
||||
val blurhash: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
@@ -265,20 +268,24 @@ fun ZoomableContentView(
|
||||
|
||||
when (content) {
|
||||
is ZoomableUrlImage ->
|
||||
UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel)
|
||||
SensitivityWarning(content.contentWarning != null, accountViewModel) {
|
||||
UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel)
|
||||
}
|
||||
is ZoomableUrlVideo ->
|
||||
VideoView(
|
||||
videoUri = content.url,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
dimensions = content.dim,
|
||||
blurhash = content.blurhash,
|
||||
roundedCorner = roundedCorner,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
SensitivityWarning(content.contentWarning != null, accountViewModel) {
|
||||
VideoView(
|
||||
videoUri = content.url,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
dimensions = content.dim,
|
||||
blurhash = content.blurhash,
|
||||
roundedCorner = roundedCorner,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
is ZoomableLocalImage ->
|
||||
LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel)
|
||||
is ZoomableLocalVideo ->
|
||||
@@ -387,13 +394,13 @@ private fun UrlImageView(
|
||||
val myModifier =
|
||||
remember {
|
||||
mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight)
|
||||
/* Is this necessary? It makes images bleed into other pages
|
||||
/* Is this necessary? It makes images bleed into other pages
|
||||
.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, false)
|
||||
} ?: this
|
||||
}
|
||||
*/
|
||||
*/
|
||||
}
|
||||
|
||||
val contentScale =
|
||||
@@ -644,6 +651,7 @@ fun aspectRatio(dim: String?): Float? {
|
||||
width / height
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
|
||||
override fun feedKey(): String {
|
||||
@@ -34,7 +36,15 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return account.flowHiddenUsers.value.hiddenUsers.map { LocalCache.getOrCreateUser(it) }
|
||||
return account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull {
|
||||
try {
|
||||
LocalCache.getOrCreateUser(it)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("HiddenAccountsFeedFilter", "Failed to parse key $it")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
@@ -180,7 +181,7 @@ private fun NotifiableIcon(
|
||||
Box(route.notifSize) {
|
||||
Icon(
|
||||
painter = painterResource(id = route.icon),
|
||||
contentDescription = null,
|
||||
contentDescription = stringResource(route.contentDescriptor),
|
||||
modifier = route.iconSize,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
||||
)
|
||||
@@ -216,7 +217,12 @@ private fun NotificationDotIcon(modifier: Modifier) {
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = Font12SP,
|
||||
modifier = remember { Modifier.wrapContentHeight().align(Alignment.TopEnd) },
|
||||
modifier =
|
||||
remember {
|
||||
Modifier
|
||||
.wrapContentHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.core.util.Consumer
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
@@ -74,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLDecoder
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
@@ -288,9 +290,13 @@ fun AppNavigation(
|
||||
route.route,
|
||||
route.arguments,
|
||||
content = {
|
||||
val decodedMessage =
|
||||
it.arguments?.getString("message")?.let {
|
||||
URLDecoder.decode(it, "utf-8")
|
||||
}
|
||||
ChatroomScreen(
|
||||
roomId = it.arguments?.getString("id"),
|
||||
draftMessage = it.arguments?.getString("message"),
|
||||
draftMessage = decodedMessage,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -354,31 +360,60 @@ fun AppNavigation(
|
||||
}
|
||||
|
||||
val activity = LocalContext.current.getActivity()
|
||||
var actionableNextPage by remember {
|
||||
mutableStateOf(uriToRoute(activity.intent?.data?.toString()?.ifBlank { null }))
|
||||
|
||||
var currentIntentNextPage by remember {
|
||||
mutableStateOf(activity.intent?.data?.toString()?.ifBlank { null })
|
||||
}
|
||||
actionableNextPage?.let {
|
||||
LaunchedEffect(it) {
|
||||
navController.navigate(it) {
|
||||
popUpTo(Route.Home.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
currentIntentNextPage?.let { intentNextPage ->
|
||||
var actionableNextPage by remember {
|
||||
mutableStateOf(uriToRoute(intentNextPage))
|
||||
}
|
||||
|
||||
LaunchedEffect(intentNextPage) {
|
||||
if (actionableNextPage != null) {
|
||||
actionableNextPage?.let {
|
||||
navController.navigate(it) {
|
||||
popUpTo(Route.Home.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
actionableNextPage = null
|
||||
}
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
R.string.invalid_nip19_uri,
|
||||
R.string.invalid_nip19_uri_description,
|
||||
intentNextPage,
|
||||
)
|
||||
}
|
||||
|
||||
currentIntentNextPage = null
|
||||
}
|
||||
actionableNextPage = null
|
||||
}
|
||||
|
||||
DisposableEffect(activity) {
|
||||
val consumer =
|
||||
Consumer<Intent> { intent ->
|
||||
val uri = intent?.data?.toString()
|
||||
val newPage = uriToRoute(uri)
|
||||
if (!uri.isNullOrBlank()) {
|
||||
val newPage = uriToRoute(uri)
|
||||
|
||||
newPage?.let { route ->
|
||||
val currentRoute = getRouteWithArguments(navController)
|
||||
if (!isSameRoute(currentRoute, route)) {
|
||||
navController.navigate(route) {
|
||||
popUpTo(Route.Home.route)
|
||||
launchSingleTop = true
|
||||
if (newPage != null) {
|
||||
val currentRoute = getRouteWithArguments(navController)
|
||||
if (!isSameRoute(currentRoute, newPage)) {
|
||||
navController.navigate(newPage) {
|
||||
popUpTo(Route.Home.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
accountViewModel.toast(
|
||||
R.string.invalid_nip19_uri,
|
||||
R.string.invalid_nip19_uri_description,
|
||||
uri,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -543,23 +542,17 @@ private fun LoggedInUserPictureDrawer(
|
||||
) {
|
||||
val profilePicture by
|
||||
accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState()
|
||||
val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex }
|
||||
|
||||
val automaticallyShowProfilePicture =
|
||||
remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = pubkeyHex,
|
||||
robot = accountViewModel.userProfile().pubkeyHex,
|
||||
model = profilePicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentDescription = stringResource(id = R.string.your_profile_image),
|
||||
modifier = HeaderPictureModifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
loadProfilePicture = automaticallyShowProfilePicture,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ fun routeToMessage(
|
||||
val withKey = ChatroomKey(persistentSetOf(user))
|
||||
accountViewModel.account.userProfile().createChatroom(withKey)
|
||||
return if (draftMessage != null) {
|
||||
"Room/${withKey.hashCode()}?message=$draftMessage"
|
||||
val encodedMessage = URLEncoder.encode(draftMessage, "utf-8")
|
||||
"Room/${withKey.hashCode()}?message=$encodedMessage"
|
||||
} else {
|
||||
"Room/${withKey.hashCode()}"
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ sealed class Route(
|
||||
val icon: Int,
|
||||
val notifSize: Modifier = Modifier.size(Size23dp),
|
||||
val iconSize: Modifier = Modifier.size(Size20dp),
|
||||
val contentDescriptor: Int = R.string.route,
|
||||
val hasNewItems: (Account, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _ ->
|
||||
false
|
||||
},
|
||||
@@ -80,6 +81,7 @@ sealed class Route(
|
||||
},
|
||||
)
|
||||
.toImmutableList(),
|
||||
contentDescriptor = R.string.route_home,
|
||||
hasNewItems = { accountViewModel, newNotes ->
|
||||
HomeLatestItem.hasNewItems(accountViewModel, newNotes)
|
||||
},
|
||||
@@ -89,27 +91,31 @@ sealed class Route(
|
||||
Route(
|
||||
route = "Global",
|
||||
icon = R.drawable.ic_globe,
|
||||
contentDescriptor = R.string.route_global,
|
||||
)
|
||||
|
||||
object Search :
|
||||
Route(
|
||||
route = "Search",
|
||||
icon = R.drawable.ic_search,
|
||||
contentDescriptor = R.string.route_search,
|
||||
)
|
||||
|
||||
object Video :
|
||||
Route(
|
||||
route = "Video",
|
||||
icon = R.drawable.ic_video,
|
||||
contentDescriptor = R.string.route_video,
|
||||
)
|
||||
|
||||
object Discover :
|
||||
Route(
|
||||
route = "Discover",
|
||||
icon = R.drawable.ic_sensors,
|
||||
hasNewItems = { accountViewModel, newNotes ->
|
||||
DiscoverLatestItem.hasNewItems(accountViewModel, newNotes)
|
||||
},
|
||||
// hasNewItems = { accountViewModel, newNotes ->
|
||||
// DiscoverLatestItem.hasNewItems(accountViewModel, newNotes)
|
||||
// },
|
||||
contentDescriptor = R.string.route_discover,
|
||||
)
|
||||
|
||||
object Notification :
|
||||
@@ -119,6 +125,7 @@ sealed class Route(
|
||||
hasNewItems = { accountViewModel, newNotes ->
|
||||
NotificationLatestItem.hasNewItems(accountViewModel, newNotes)
|
||||
},
|
||||
contentDescriptor = R.string.route_notifications,
|
||||
)
|
||||
|
||||
object Message :
|
||||
@@ -128,18 +135,21 @@ sealed class Route(
|
||||
hasNewItems = { accountViewModel, newNotes ->
|
||||
MessagesLatestItem.hasNewItems(accountViewModel, newNotes)
|
||||
},
|
||||
contentDescriptor = R.string.route_messages,
|
||||
)
|
||||
|
||||
object BlockedUsers :
|
||||
Route(
|
||||
route = "BlockedUsers",
|
||||
icon = R.drawable.ic_security,
|
||||
contentDescriptor = R.string.route_security_filters,
|
||||
)
|
||||
|
||||
object Bookmarks :
|
||||
Route(
|
||||
route = "Bookmarks",
|
||||
icon = R.drawable.ic_bookmarks,
|
||||
contentDescriptor = R.string.route_home,
|
||||
)
|
||||
|
||||
object Profile :
|
||||
|
||||
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -162,7 +163,7 @@ fun BadgeCompose(
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -78,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
@@ -491,8 +493,14 @@ private fun RenderReply(
|
||||
onWantsToReply: (Note) -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val replyTo by remember { derivedStateOf { note.replyTo?.lastOrNull() } }
|
||||
replyTo?.let { note ->
|
||||
val replyTo =
|
||||
produceState(initialValue = note.replyTo?.lastOrNull()) {
|
||||
accountViewModel.unwrapIfNeeded(value) {
|
||||
value = it
|
||||
}
|
||||
}
|
||||
|
||||
replyTo.value?.let { note ->
|
||||
ChatroomMessageCompose(
|
||||
note,
|
||||
null,
|
||||
@@ -619,19 +627,18 @@ private fun RenderRegularTextNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
val modifier = remember { Modifier.padding(top = 5.dp) }
|
||||
|
||||
LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent ->
|
||||
if (eventContent != null) {
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent!!,
|
||||
content = eventContent,
|
||||
canPreview = canPreview,
|
||||
modifier = modifier,
|
||||
modifier = HalfTopPadding,
|
||||
tags = tags,
|
||||
backgroundColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -642,8 +649,8 @@ private fun RenderRegularTextNote(
|
||||
TranslatableRichTextViewer(
|
||||
content = stringResource(id = R.string.could_not_decrypt_the_message),
|
||||
canPreview = true,
|
||||
modifier = modifier,
|
||||
tags = tags,
|
||||
modifier = HalfTopPadding,
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
|
||||
@@ -152,7 +152,7 @@ fun LikeIcon(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_like),
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.like_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = grayTint,
|
||||
)
|
||||
@@ -165,7 +165,7 @@ fun RepostedIcon(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.boost_or_quote_description),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
)
|
||||
@@ -198,10 +198,11 @@ fun ZappedIcon(modifier: Modifier) {
|
||||
fun ZapIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
contentDescriptor: Int = R.string.zap_description,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
contentDescription = stringResource(contentDescriptor),
|
||||
tint = tint,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -244,20 +245,26 @@ fun OpenInNewIcon(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpandLessIcon(modifier: Modifier) {
|
||||
fun ExpandLessIcon(
|
||||
modifier: Modifier,
|
||||
contentDescriptor: Int,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandLess,
|
||||
null,
|
||||
contentDescription = stringResource(id = contentDescriptor),
|
||||
modifier = modifier,
|
||||
tint = MaterialTheme.colorScheme.subtleButton,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpandMoreIcon(modifier: Modifier) {
|
||||
fun ExpandMoreIcon(
|
||||
modifier: Modifier,
|
||||
contentDescriptor: Int,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
contentDescription = stringResource(id = contentDescriptor),
|
||||
modifier = modifier,
|
||||
tint = MaterialTheme.colorScheme.subtleButton,
|
||||
)
|
||||
@@ -270,7 +277,7 @@ fun CommentIcon(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
contentDescription = null,
|
||||
contentDescription = stringResource(id = R.string.reply_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
)
|
||||
@@ -293,7 +300,7 @@ fun ViewCountIcon(
|
||||
fun PollIcon() {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_poll),
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.poll),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
@@ -303,7 +310,7 @@ fun PollIcon() {
|
||||
fun RegularPostIcon() {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_lists),
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.disable_poll),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
@@ -435,10 +442,10 @@ fun LinkIcon(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalDotsIcon() {
|
||||
fun VerticalDotsIcon(contentDescriptor: Int? = null) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
null,
|
||||
contentDescription = contentDescriptor?.let { stringResource(id = it) },
|
||||
modifier = Size18Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
@@ -542,11 +542,6 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
|
||||
author: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val automaticallyShowProfilePicture =
|
||||
remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
WatchUserMetadata(author) { baseUserPicture ->
|
||||
// Crossfade(targetState = baseUserPicture) { userPicture ->
|
||||
RobohashFallbackAsyncImage(
|
||||
@@ -555,7 +550,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = MaterialTheme.colorScheme.profile35dpModifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
loadProfilePicture = automaticallyShowProfilePicture,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
|
||||
)
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -795,7 +795,11 @@ fun ShortCommunityHeader(
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 10.dp).height(Size35dp).weight(1f),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.height(Size35dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
@@ -808,7 +812,10 @@ fun ShortCommunityHeader(
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.height(Size35dp).padding(start = 5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.height(Size35dp)
|
||||
.padding(start = 5.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
ShortCommunityActionOptions(baseNote, accountViewModel, nav)
|
||||
@@ -1429,7 +1436,8 @@ fun RenderAppDefinition(
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(125.dp)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
@@ -1449,12 +1457,19 @@ fun RenderAppDefinition(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
contentDescription = stringResource(id = R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth().height(125.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(125.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(top = 75.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp)
|
||||
.padding(top = 75.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -1469,11 +1484,12 @@ fun RenderAppDefinition(
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier.border(
|
||||
3.dp,
|
||||
MaterialTheme.colorScheme.background,
|
||||
CircleShape,
|
||||
)
|
||||
Modifier
|
||||
.border(
|
||||
3.dp,
|
||||
MaterialTheme.colorScheme.background,
|
||||
CircleShape,
|
||||
)
|
||||
.clip(shape = CircleShape)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
@@ -1496,7 +1512,10 @@ fun RenderAppDefinition(
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.height(Size35dp).padding(bottom = 3.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.height(Size35dp)
|
||||
.padding(bottom = 3.dp),
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1681,7 +1700,10 @@ fun DisplayRelaySet(
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -1690,7 +1712,10 @@ fun DisplayRelaySet(
|
||||
text = it,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.Gray,
|
||||
)
|
||||
@@ -1705,7 +1730,10 @@ fun DisplayRelaySet(
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 10.dp, bottom = 5.dp).weight(1f),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 10.dp, bottom = 5.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
@@ -1720,7 +1748,8 @@ fun DisplayRelaySet(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.background(getGradient(backgroundColor)),
|
||||
) {
|
||||
@@ -1785,7 +1814,10 @@ fun DisplayPeopleList(
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -1810,7 +1842,8 @@ fun DisplayPeopleList(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.background(getGradient(backgroundColor)),
|
||||
) {
|
||||
@@ -1840,7 +1873,10 @@ private fun RenderBadgeAward(
|
||||
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
|
||||
awardees.take(100).forEach { user ->
|
||||
Row(
|
||||
modifier = Modifier.size(size = Size35dp).clickable { nav("User/${user.pubkeyHex}") },
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size = Size35dp)
|
||||
.clickable { nav("User/${user.pubkeyHex}") },
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
@@ -1952,7 +1988,10 @@ fun RenderPostApproval(
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -2076,7 +2115,10 @@ public fun RenderEmojiPack(
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1F).padding(5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.padding(5.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -2116,7 +2158,8 @@ public fun RenderEmojiPack(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.background(getGradient(backgroundColor)),
|
||||
) {
|
||||
@@ -2189,7 +2232,10 @@ fun RenderPinListEvent(
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -2221,7 +2267,8 @@ fun RenderPinListEvent(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.background(getGradient(backgroundColor)),
|
||||
) {
|
||||
@@ -2320,7 +2367,8 @@ private fun RenderReport(
|
||||
baseNote = it,
|
||||
isQuotedNote = true,
|
||||
modifier =
|
||||
Modifier.padding(top = 5.dp)
|
||||
Modifier
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
@@ -2610,7 +2658,7 @@ fun MoreOptionsButton(
|
||||
modifier = Size24Modifier,
|
||||
onClick = enablePopup,
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
VerticalDotsIcon(R.string.note_options)
|
||||
|
||||
NoteDropDownMenu(
|
||||
baseNote,
|
||||
@@ -2925,7 +2973,8 @@ fun BadgeDisplay(baseNote: Note) {
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(10.dp)
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.clip(shape = CutCornerShape(20, 20, 20, 20))
|
||||
.border(
|
||||
5.dp,
|
||||
@@ -2975,7 +3024,10 @@ private fun RenderBadge(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp),
|
||||
color = backgroundFromImage,
|
||||
)
|
||||
}
|
||||
@@ -2985,7 +3037,10 @@ private fun RenderBadge(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -3103,7 +3158,10 @@ fun VideoDisplay(
|
||||
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 5.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (isYouTube) {
|
||||
@@ -3139,7 +3197,10 @@ fun VideoDisplay(
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3291,9 +3352,11 @@ fun AudioTrackHeader(
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier =
|
||||
Modifier.padding(top = 5.dp, start = 10.dp, end = 10.dp).clickable {
|
||||
nav("User/${it.second.pubkeyHex}")
|
||||
},
|
||||
Modifier
|
||||
.padding(top = 5.dp, start = 10.dp, end = 10.dp)
|
||||
.clickable {
|
||||
nav("User/${it.second.pubkeyHex}")
|
||||
},
|
||||
) {
|
||||
ClickableUserPicture(it.second, 25.dp, accountViewModel)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
@@ -3373,7 +3436,10 @@ fun AudioHeader(
|
||||
content?.let {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 5.dp),
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
@@ -3432,7 +3498,10 @@ fun RenderLiveActivityEventInner(
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp).fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 5.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
subject?.let {
|
||||
Text(
|
||||
@@ -3492,7 +3561,10 @@ fun RenderLiveActivityEventInner(
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(10.dp).height(100.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.height(100.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.live_stream_is_offline),
|
||||
@@ -3505,7 +3577,10 @@ fun RenderLiveActivityEventInner(
|
||||
} else if (status == STATUS_ENDED) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(10.dp).height(100.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.height(100.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.live_stream_has_ended),
|
||||
@@ -3519,7 +3594,10 @@ fun RenderLiveActivityEventInner(
|
||||
participantUsers.forEach {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp).clickable { nav("User/${it.second.pubkeyHex}") },
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 5.dp)
|
||||
.clickable { nav("User/${it.second.pubkeyHex}") },
|
||||
) {
|
||||
ClickableUserPicture(it.second, 25.dp, accountViewModel)
|
||||
Spacer(StdHorzSpacer)
|
||||
@@ -3551,7 +3629,8 @@ private fun LongFormHeader(
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(top = Size5dp)
|
||||
Modifier
|
||||
.padding(top = Size5dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
@@ -3582,7 +3661,10 @@ private fun LongFormHeader(
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3590,7 +3672,10 @@ private fun LongFormHeader(
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -3616,7 +3701,8 @@ private fun RenderClassifieds(
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.clip(shape = QuoteBorder)
|
||||
Modifier
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.subtleBorder,
|
||||
@@ -3674,7 +3760,12 @@ private fun RenderClassifieds(
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = remember { Modifier.clip(SmallBorder).padding(start = 5.dp) },
|
||||
modifier =
|
||||
remember {
|
||||
Modifier
|
||||
.clip(SmallBorder)
|
||||
.padding(start = 5.dp)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3767,11 +3858,22 @@ fun CreateImageHeader(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
contentDescription = stringResource(R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = remember { Modifier.fillMaxWidth().height(150.dp) },
|
||||
modifier =
|
||||
remember {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
remember { Modifier.width(75.dp).height(75.dp).padding(10.dp).align(Alignment.BottomStart) },
|
||||
remember {
|
||||
Modifier
|
||||
.width(75.dp)
|
||||
.height(75.dp)
|
||||
.padding(10.dp)
|
||||
.align(Alignment.BottomStart)
|
||||
},
|
||||
) {
|
||||
NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp)
|
||||
}
|
||||
|
||||
@@ -379,9 +379,9 @@ private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableStat
|
||||
label = "RenderShowIndividualReactionsButton",
|
||||
) {
|
||||
if (it) {
|
||||
ExpandLessIcon(modifier = Size22Modifier)
|
||||
ExpandLessIcon(modifier = Size22Modifier, R.string.close_all_reactions_to_this_post)
|
||||
} else {
|
||||
ExpandMoreIcon(modifier = Size22Modifier)
|
||||
ExpandMoreIcon(modifier = Size22Modifier, R.string.open_all_reactions_to_this_post)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
@@ -87,7 +89,7 @@ private fun ShowMoreRelaysButton(onClick: () -> Unit) {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.expand_relay_list),
|
||||
modifier = ShowMoreRelaysButtonIconModifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
@@ -119,7 +119,7 @@ fun ChatRelayExpandButton(onClick: () -> Unit) {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
null,
|
||||
contentDescription = stringResource(id = R.string.expand_relay_list),
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
@@ -156,7 +156,8 @@ fun RenderRelay(
|
||||
|
||||
val clickableModifier =
|
||||
remember(relay) {
|
||||
Modifier.padding(1.dp)
|
||||
Modifier
|
||||
.padding(1.dp)
|
||||
.size(Size15dp)
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
@@ -175,18 +176,21 @@ fun RenderRelay(
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
@@ -223,7 +227,7 @@ fun RenderRelayIcon(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = displayUrl,
|
||||
model = iconUrl,
|
||||
contentDescription = stringResource(id = R.string.relay_icon),
|
||||
contentDescription = stringResource(id = R.string.relay_info, displayUrl),
|
||||
colorFilter = RelayIconFilter,
|
||||
modifier = iconModifier,
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
|
||||
@@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
|
||||
var nextAmount by mutableStateOf(TextFieldValue(""))
|
||||
@@ -149,6 +150,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
|
||||
try {
|
||||
decodePublicKey(walletConnectPubkey.text.trim()).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
@@ -168,6 +170,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
|
||||
try {
|
||||
unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
@@ -340,10 +340,11 @@ fun BaseUserPicture(
|
||||
val myIconSize by remember(size) { derivedStateOf { size.div(3.5f) } }
|
||||
|
||||
Box(outerModifier, contentAlignment = Alignment.TopEnd) {
|
||||
LoadUserProfilePicture(baseUser) { userProfilePicture ->
|
||||
LoadUserProfilePicture(baseUser) { userProfilePicture, userName ->
|
||||
InnerUserPicture(
|
||||
userHex = baseUser.pubkeyHex,
|
||||
userPicture = userProfilePicture,
|
||||
userName = userName,
|
||||
size = size,
|
||||
modifier = innerModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -357,17 +358,18 @@ fun BaseUserPicture(
|
||||
@Composable
|
||||
fun LoadUserProfilePicture(
|
||||
baseUser: User,
|
||||
innerContent: @Composable (String?) -> Unit,
|
||||
innerContent: @Composable (String?, String?) -> Unit,
|
||||
) {
|
||||
val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture())
|
||||
val userProfile by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
|
||||
|
||||
innerContent(userProfile)
|
||||
innerContent(userProfile?.profilePicture(), userProfile?.bestDisplayName() ?: userProfile?.bestDisplayName())
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InnerUserPicture(
|
||||
userHex: String,
|
||||
userPicture: String?,
|
||||
userName: String?,
|
||||
size: Dp,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -386,7 +388,12 @@ fun InnerUserPicture(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = userHex,
|
||||
model = userPicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentDescription =
|
||||
if (userName != null) {
|
||||
stringResource(id = R.string.profile_image_of_user, userName)
|
||||
} else {
|
||||
stringResource(id = R.string.profile_image)
|
||||
},
|
||||
modifier = myImageModifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
loadProfilePicture = automaticallyShowProfilePicture,
|
||||
|
||||
@@ -275,7 +275,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
refreshChartModel()
|
||||
}
|
||||
|
||||
suspend fun addToStatsSuspend(newNotes: Set<Note>) {
|
||||
suspend fun addToStatsSuspend(newBlockNotes: Set<Set<Note>>) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val currentUser = user.pubkeyHex
|
||||
@@ -287,39 +287,41 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
val takenIntoAccount = this.takenIntoAccount.toMutableSet()
|
||||
var hasNewElements = false
|
||||
|
||||
newNotes.forEach {
|
||||
val noteEvent = it.event
|
||||
if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) {
|
||||
if (noteEvent is ReactionEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
reactions[netDate] = (reactions[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
}
|
||||
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt())
|
||||
boosts[netDate] = (boosts[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
}
|
||||
} else if (noteEvent is LnZapEvent) {
|
||||
if (
|
||||
noteEvent.isTaggedUser(currentUser)
|
||||
) { // && noteEvent.pubKey != currentUser User might be sending his own receipts
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
zaps[netDate] =
|
||||
(zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
}
|
||||
} else if (noteEvent is TextNoteEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
replies[netDate] = (replies[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
newBlockNotes.forEach { newNotes ->
|
||||
newNotes.forEach {
|
||||
val noteEvent = it.event
|
||||
if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) {
|
||||
if (noteEvent is ReactionEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
reactions[netDate] = (reactions[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
}
|
||||
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey() != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt())
|
||||
boosts[netDate] = (boosts[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
}
|
||||
} else if (noteEvent is LnZapEvent) {
|
||||
if (
|
||||
noteEvent.isTaggedUser(currentUser)
|
||||
) { // && noteEvent.pubKey != currentUser User might be sending his own receipts
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
zaps[netDate] =
|
||||
(zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
}
|
||||
} else if (noteEvent is TextNoteEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
replies[netDate] = (replies[netDate] ?: 0) + 1
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
hasNewElements = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,7 +424,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
|
||||
|
||||
fun invalidateInsertData(newItems: Set<Note>) {
|
||||
bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it.flatten().toSet()) }
|
||||
bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
||||
@@ -213,7 +213,10 @@ fun DrawPlayName(name: String) {
|
||||
@Composable
|
||||
fun DrawPlayNameIcon(onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick, modifier = StdButtonSizeModifier) {
|
||||
PlayIcon(modifier = StdButtonSizeModifier, tint = MaterialTheme.colorScheme.placeholderText)
|
||||
PlayIcon(
|
||||
modifier = StdButtonSizeModifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class ZapOptionstViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
@@ -104,11 +105,7 @@ class ZapOptionstViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun value(): Long? {
|
||||
return try {
|
||||
customAmount.text.trim().toLongOrNull()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
return customAmount.text.trim().toLongOrNull()
|
||||
}
|
||||
|
||||
fun cancel() {}
|
||||
@@ -283,7 +280,7 @@ fun ZapButton(
|
||||
onPost: () -> Unit,
|
||||
) {
|
||||
Button(
|
||||
onClick = { onPost() },
|
||||
onClick = { if (isActive) onPost() },
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
@@ -446,6 +443,7 @@ fun payViaIntent(
|
||||
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
if (e.message != null) {
|
||||
onError(context.getString(R.string.no_wallet_found_with_error, e.message!!))
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@Composable
|
||||
fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
|
||||
@@ -51,6 +52,7 @@ fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
|
||||
onScan(null)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("NIP19 Scanner", "Error parsing $it", e)
|
||||
// QR can be anything, do not throw errors.
|
||||
onScan(null)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -127,6 +128,8 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -284,26 +287,27 @@ fun Modifier.drawReplyLevel(
|
||||
color: Color,
|
||||
selected: Color,
|
||||
): Modifier =
|
||||
this.drawBehind {
|
||||
val paddingDp = 2
|
||||
val strokeWidthDp = 2
|
||||
val levelWidthDp = strokeWidthDp + 1
|
||||
this
|
||||
.drawBehind {
|
||||
val paddingDp = 2
|
||||
val strokeWidthDp = 2
|
||||
val levelWidthDp = strokeWidthDp + 1
|
||||
|
||||
val padding = paddingDp.dp.toPx()
|
||||
val strokeWidth = strokeWidthDp.dp.toPx()
|
||||
val levelWidth = levelWidthDp.dp.toPx()
|
||||
val padding = paddingDp.dp.toPx()
|
||||
val strokeWidth = strokeWidthDp.dp.toPx()
|
||||
val levelWidth = levelWidthDp.dp.toPx()
|
||||
|
||||
repeat(level) {
|
||||
this.drawLine(
|
||||
if (it == level - 1) selected else color,
|
||||
Offset(padding + it * levelWidth, 0f),
|
||||
Offset(padding + it * levelWidth, size.height),
|
||||
strokeWidth = strokeWidth,
|
||||
)
|
||||
repeat(level) {
|
||||
this.drawLine(
|
||||
if (it == level - 1) selected else color,
|
||||
Offset(padding + it * levelWidth, 0f),
|
||||
Offset(padding + it * levelWidth, size.height),
|
||||
strokeWidth = strokeWidth,
|
||||
)
|
||||
}
|
||||
|
||||
return@drawBehind
|
||||
}
|
||||
|
||||
return@drawBehind
|
||||
}
|
||||
.padding(start = (2 + (level * 3)).dp)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -353,11 +357,14 @@ fun NoteMaster(
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(start = 12.dp, end = 12.dp)
|
||||
Modifier
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
.clickable(onClick = { note.author?.let { nav("User/${it.pubkeyHex}") } }),
|
||||
) {
|
||||
NoteAuthorPicture(
|
||||
@@ -391,13 +398,13 @@ fun NoteMaster(
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
modifier = Size24Modifier,
|
||||
onClick = enablePopup,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
@@ -443,7 +450,8 @@ fun NoteMaster(
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(horizontal = 12.dp)
|
||||
Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { popupExpanded = true },
|
||||
@@ -669,7 +677,9 @@ private fun RenderClassifiedsReaderForThread(
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp).fillMaxWidth(),
|
||||
Modifier
|
||||
.padding(start = 20.dp, end = 20.dp, bottom = 5.dp, top = 15.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
@@ -686,7 +696,9 @@ private fun RenderClassifiedsReaderForThread(
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp).fillMaxWidth(),
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 5.dp, top = 5.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -703,6 +715,7 @@ private fun RenderClassifiedsReaderForThread(
|
||||
}
|
||||
|
||||
var message by remember { mutableStateOf(TextFieldValue(msg)) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
TextField(
|
||||
value = message,
|
||||
@@ -725,7 +738,11 @@ private fun RenderClassifiedsReaderForThread(
|
||||
isActive = message.text.isNotBlank(),
|
||||
modifier = EditFieldTrailingIconModifier,
|
||||
) {
|
||||
note.author?.let { nav(routeToMessage(it, msg, accountViewModel)) }
|
||||
scope.launch(Dispatchers.IO) {
|
||||
note.author?.let {
|
||||
nav(routeToMessage(it, note.toNostrUri() + "\n\n" + msg, accountViewModel))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors =
|
||||
|
||||
@@ -36,23 +36,48 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillNode
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalAutofill
|
||||
import androidx.compose.ui.platform.LocalAutofillTree
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -67,10 +92,14 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.note.authenticate
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNsec
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun AccountBackupDialog(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -80,12 +109,23 @@ fun AccountBackupDialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxSize(),
|
||||
modifier =
|
||||
Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -93,7 +133,10 @@ fun AccountBackupDialog(
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 30.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -101,13 +144,105 @@ fun AccountBackupDialog(
|
||||
style = RichTextStyle().resolveDefaults(),
|
||||
) {
|
||||
Markdown(
|
||||
content = stringResource(R.string.account_backup_tips_md),
|
||||
content = stringResource(R.string.account_backup_tips2_md),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
NSecCopyButton(accountViewModel)
|
||||
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
|
||||
Material3RichText(
|
||||
style = RichTextStyle().resolveDefaults(),
|
||||
) {
|
||||
Markdown(
|
||||
content = stringResource(R.string.account_backup_tips3_md),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
val password = remember { mutableStateOf(TextFieldValue("")) }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
var showCharsPassword by remember { mutableStateOf(false) }
|
||||
|
||||
val autofillNode =
|
||||
AutofillNode(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = { password.value = TextFieldValue(it) },
|
||||
)
|
||||
val autofill = LocalAutofill.current
|
||||
LocalAutofillTree.current += autofillNode
|
||||
|
||||
OutlinedTextField(
|
||||
modifier =
|
||||
Modifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
autofillNode.boundingBox = coordinates.boundsInWindow()
|
||||
}
|
||||
.onFocusChanged { focusState ->
|
||||
autofill?.run {
|
||||
if (focusState.isFocused) {
|
||||
requestAutofillForNode(autofillNode)
|
||||
} else {
|
||||
cancelAutofillForNode(autofillNode)
|
||||
}
|
||||
}
|
||||
},
|
||||
value = password.value,
|
||||
onValueChange = {
|
||||
password.value = it
|
||||
if (errorMessage.isNotEmpty()) {
|
||||
errorMessage = ""
|
||||
}
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.ncryptsec_password),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Row {
|
||||
IconButton(onClick = { showCharsPassword = !showCharsPassword }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription =
|
||||
if (showCharsPassword) {
|
||||
stringResource(R.string.show_password)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.hide_password,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
visualTransformation =
|
||||
if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
)
|
||||
|
||||
if (errorMessage.isNotBlank()) {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
EncryptNSecCopyButton(accountViewModel, password)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +295,56 @@ private fun NSecCopyButton(accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EncryptNSecCopyButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
password: MutableState<TextFieldValue>,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val keyguardLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
result: ActivityResult ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
authenticate(
|
||||
title = context.getString(R.string.copy_my_secret_key),
|
||||
context = context,
|
||||
keyguardLauncher = keyguardLauncher,
|
||||
onApproved = { encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) },
|
||||
onError = { title, message -> accountViewModel.toast(title, message) },
|
||||
)
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
contentPadding = ButtonPadding,
|
||||
enabled = password.value.text.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
imageVector = Icons.Default.Key,
|
||||
contentDescription =
|
||||
stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup),
|
||||
modifier = Modifier.padding(end = 5.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(id = R.string.encrypt_and_copy_my_secret_key),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getFragmentActivity(): FragmentActivity? {
|
||||
var currentContext = this
|
||||
while (currentContext is ContextWrapper) {
|
||||
@@ -189,3 +374,46 @@ private fun copyNSec(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encryptCopyNSec(
|
||||
password: MutableState<TextFieldValue>,
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
accountViewModel: AccountViewModel,
|
||||
clipboardManager: ClipboardManager,
|
||||
) {
|
||||
if (password.value.text.isBlank()) {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.password_is_required),
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
accountViewModel.account.keyPair.privKey?.let {
|
||||
val key = CryptoUtils.encryptNIP49(it.toHexKey(), password.value.text)
|
||||
if (key != null) {
|
||||
clipboardManager.setText(AnnotatedString(key))
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.secret_key_copied_to_clipboard),
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.failed_to_encrypt_key),
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.CashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CashuToken
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -87,6 +88,7 @@ import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
@@ -103,7 +105,11 @@ import kotlin.time.measureTimedValue
|
||||
|
||||
@Immutable class StringToastMsg(val title: String, val msg: String) : ToastMsg()
|
||||
|
||||
@Immutable class ResourceToastMsg(val titleResId: Int, val resourceId: Int) : ToastMsg()
|
||||
@Immutable class ResourceToastMsg(
|
||||
val titleResId: Int,
|
||||
val resourceId: Int,
|
||||
val params: Array<out String>? = null,
|
||||
) : ToastMsg()
|
||||
|
||||
@Stable
|
||||
class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao {
|
||||
@@ -139,6 +145,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) }
|
||||
}
|
||||
|
||||
fun toast(
|
||||
titleResId: Int,
|
||||
resourceId: Int,
|
||||
vararg params: String,
|
||||
) {
|
||||
viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId, params)) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean {
|
||||
return account.isWriteable()
|
||||
}
|
||||
@@ -1042,7 +1056,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.proxyPort = portNumber.value.toInt()
|
||||
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
|
||||
account.proxy = HttpClientManager.initProxy(checked, "127.0.0.1", account.proxyPort)
|
||||
account.saveable.invalidateData()
|
||||
serviceManager?.forceRestart()
|
||||
}
|
||||
@@ -1102,6 +1116,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
val myCover = context.imageLoader.execute(request).drawable
|
||||
onReady(myCover)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("VideoView", "Fail to load cover $thumbUri", e)
|
||||
onError(e.message)
|
||||
}
|
||||
@@ -1173,6 +1188,55 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun unwrapIfNeeded(
|
||||
event: EventInterface?,
|
||||
onReady: (Note) -> Unit,
|
||||
) {
|
||||
when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
event.cachedGift(account.signer) {
|
||||
val existingNote = LocalCache.getNoteIfExists(it.id)
|
||||
if (existingNote != null) {
|
||||
unwrapIfNeeded(existingNote.event, onReady)
|
||||
} else {
|
||||
LocalCache.verifyAndConsume(it, null)
|
||||
unwrapIfNeeded(it, onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
event.cachedGossip(account.signer) {
|
||||
val existingNote = LocalCache.getNoteIfExists(it.id)
|
||||
if (existingNote != null) {
|
||||
unwrapIfNeeded(existingNote.event, onReady)
|
||||
} else {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
unwrapIfNeeded(it, onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
event?.id()?.let {
|
||||
LocalCache.getNoteIfExists(it)?.let {
|
||||
onReady(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unwrapIfNeeded(
|
||||
note: Note?,
|
||||
onReady: (Note) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
unwrapIfNeeded(note?.event) {
|
||||
onReady(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HasNotificationDot(bottomNavigationItems: ImmutableList<Route>) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user