mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6adcde75 | ||
|
|
ad65e02fc9 | ||
|
|
249ac4e039 | ||
|
|
a92ef7a375 | ||
|
|
e65faa2076 | ||
|
|
5f53fc6aa9 | ||
|
|
218e133857 | ||
|
|
fcd61d87be | ||
|
|
fb280f4880 | ||
|
|
5342c4cfba | ||
|
|
dd106f315f | ||
|
|
3ddd06fafc | ||
|
|
4fb68dd014 | ||
|
|
a301421915 | ||
|
|
16c171ec40 | ||
|
|
6725114b48 | ||
|
|
861ad218c8 | ||
|
|
b694ac7259 | ||
|
|
81cc985e3b | ||
|
|
786802b708 | ||
|
|
c53fc03157 | ||
|
|
48a6923537 | ||
|
|
c34811e05b | ||
|
|
3f500ef5b3 | ||
|
|
cbce22c994 | ||
|
|
bc7a578cfe | ||
|
|
af784a5bda | ||
|
|
b1a355691a | ||
|
|
4f2b2992e0 | ||
|
|
77d30b6e7c | ||
|
|
228de99ccf | ||
|
|
dfec7ae7f5 | ||
|
|
f8c4242843 | ||
|
|
34150b6725 | ||
|
|
21a18cfa38 | ||
|
|
8fa6d1d490 | ||
|
|
c23510e05e | ||
|
|
1cc4fd5183 | ||
|
|
7468352795 | ||
|
|
238e577b93 | ||
|
|
0653a69bbd | ||
|
|
87fcba02b3 | ||
|
|
3fb8a5938f | ||
|
|
f2a9c2b34b | ||
|
|
cf1e5f952d | ||
|
|
b6e096ec96 | ||
|
|
ee4e20bac8 | ||
|
|
4d2a7bbc32 | ||
|
|
600aab5e8d | ||
|
|
1efef5887a | ||
|
|
3495866017 | ||
|
|
0755bd2474 | ||
|
|
d257f7e253 | ||
|
|
7497237763 | ||
|
|
d506cf13a9 | ||
|
|
05454f5420 | ||
|
|
8d8990899b | ||
|
|
fd37826663 | ||
|
|
0084c2b532 | ||
|
|
c686e775bf | ||
|
|
6e0701b65d | ||
|
|
417c29bd72 | ||
|
|
3eb6983a94 | ||
|
|
d4ba135368 | ||
|
|
7f35d7d416 | ||
|
|
9f4a30657b | ||
|
|
86aab2e842 | ||
|
|
73e3b60721 | ||
|
|
fdb0830d1b | ||
|
|
bc833fadcf | ||
|
|
d36efd6a2c | ||
|
|
0d92384d8c | ||
|
|
466929a0f7 | ||
|
|
85059d775b | ||
|
|
328ed226c6 | ||
|
|
e305f3c198 | ||
|
|
f78a252c8f | ||
|
|
6e65bdbb91 | ||
|
|
9639514a82 | ||
|
|
87fabd54de | ||
|
|
529403d92f | ||
|
|
e2d86c1727 | ||
|
|
11ff0736e4 | ||
|
|
fbc63a6df1 | ||
|
|
7dccac41e9 | ||
|
|
8ebad524ff | ||
|
|
bf67ff64b4 | ||
|
|
85ecaf18cc | ||
|
|
3c36f52baf | ||
|
|
782926fbea | ||
|
|
21d141c5c2 | ||
|
|
de17aab7ab | ||
|
|
d1b0707b54 | ||
|
|
2ea80272e3 | ||
|
|
6a7d33e58f | ||
|
|
880915e702 | ||
|
|
d6fd407632 | ||
|
|
1c0854eeaa | ||
|
|
9eefb65047 | ||
|
|
31c9041461 | ||
|
|
c4d6b1a5d1 | ||
|
|
ad03d355b0 | ||
|
|
5ad86e5604 | ||
|
|
6afd9a0191 | ||
|
|
58ff0b5500 | ||
|
|
3f33871c9c | ||
|
|
ca4cc93b31 | ||
|
|
49c2d4f486 | ||
|
|
b9c3123f18 | ||
|
|
452911bf27 | ||
|
|
173e82d236 | ||
|
|
2819c71e85 | ||
|
|
3eac579638 | ||
|
|
d606cef90e | ||
|
|
de8870f240 | ||
|
|
13031223a4 | ||
|
|
553500460a | ||
|
|
a7e601beee | ||
|
|
d21792a46f | ||
|
|
b8b9aaf4aa | ||
|
|
dd160ecb71 | ||
|
|
2ee4b24c9b | ||
|
|
606c483d09 | ||
|
|
b1d1c4089c | ||
|
|
5740b75a64 | ||
|
|
8c9971d9f2 | ||
|
|
860c3773c3 | ||
|
|
8ff4ac3709 | ||
|
|
5b77e39c8b | ||
|
|
5886c866d3 | ||
|
|
96f29fc5ca | ||
|
|
90175198f0 | ||
|
|
4b9a55e178 | ||
|
|
0429c4dc3a | ||
|
|
e3f3350c85 | ||
|
|
5cd1c5ab90 | ||
|
|
d982f029e4 | ||
|
|
f44bc02767 | ||
|
|
2e096069b9 | ||
|
|
f10dd38126 | ||
|
|
74b1cad578 | ||
|
|
dbcf59779e | ||
|
|
f4cee92702 | ||
|
|
b70407f447 | ||
|
|
444857ad95 | ||
|
|
ae291d278f | ||
|
|
0d44c44c6e | ||
|
|
a03f9d2b98 | ||
|
|
49a090da00 | ||
|
|
c8d11fdcac | ||
|
|
33342b85d1 | ||
|
|
a148da4b7b | ||
|
|
1b93fc57cb | ||
|
|
2514b74883 | ||
|
|
a97ade41fa | ||
|
|
e44ee1e549 | ||
|
|
b170d04d5e | ||
|
|
dc55d71486 | ||
|
|
dd071dc65a | ||
|
|
dd7427971c | ||
|
|
d715ea44df | ||
|
|
7af2fcdb27 | ||
|
|
879f7cf8e3 | ||
|
|
88033c2acd | ||
|
|
e7fc5545ba | ||
|
|
e7240c0acf | ||
|
|
1fa8cd95dc |
7
.github/workflows/create-release.yml
vendored
7
.github/workflows/create-release.yml
vendored
@@ -227,4 +227,9 @@ jobs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
|
||||
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Drafts a description for the release
|
||||
uses: release-drafter/release-drafter@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
120
README.md
120
README.md
@@ -1,22 +1,42 @@
|
||||
# Amethyst: Nostr client for Android
|
||||
<div align="center">
|
||||
|
||||
<a href="https://amethyst.social">
|
||||
<img src="./docs/design/3rd%20Logo%20-%20Zitron/amethyst.svg" alt="Amethyst Logo" title="Amethyst logo" width="80"/>
|
||||
</a>
|
||||
|
||||
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px"/>
|
||||
# Amethyst
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
## Nostr Client for Android
|
||||
|
||||
Join the social network you control.
|
||||
|
||||
[](https://github.com/vitorpamplona/amethyst/releases)
|
||||
[](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
[](https://github.com/vitorpamplona/amethyst)
|
||||
[](https://jitpack.io/#vitorpamplona/amethyst)
|
||||
[](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
|
||||
[](/LICENSE)
|
||||
|
||||
## Download and Install
|
||||
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
height="80">](https://github.com/ImranR98/Obtainium)
|
||||
height="70">](https://github.com/ImranR98/Obtainium)
|
||||
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
|
||||
height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
|
||||
height="70">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
|
||||
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
|
||||
alt="Get it on Google Play"
|
||||
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
height="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
# Current Features
|
||||
</div>
|
||||
|
||||
## Supported Features
|
||||
|
||||
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px">
|
||||
|
||||
- [x] Events / Relay Subscriptions (NIP-01)
|
||||
- [x] Follow List (NIP-02)
|
||||
@@ -103,31 +123,41 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [ ] Workspaces
|
||||
- [ ] Infinity Scroll
|
||||
|
||||
## Privacy and Information Permanence
|
||||
|
||||
Relays know your IP address, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) except for Private Zaps and Private DMs. While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
|
||||
|
||||
If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN or Tor) from trackers online.
|
||||
|
||||
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
|
||||
|
||||
Information shared on Nostr can be re-broadcasted to other servers and should be assumed permanent for privacy purposes. There is no way to guarantee the deletion of any content once posted.
|
||||
|
||||
# Development Overview
|
||||
|
||||
## Overall Architecture
|
||||
This repository is split between Amethyst and Quartz:
|
||||
- Amethyst is a native Android app made with Kotlin and Jetpack Compose.
|
||||
- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
|
||||
|
||||
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.
|
||||
The overall architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
|
||||
The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
|
||||
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
|
||||
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData and Flow objects to
|
||||
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.
|
||||
They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes
|
||||
are updated 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 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.
|
||||
Lastly, the user's account information (private key/pub key) is stored in the Android KeyStore for security.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to have the following pre-requisites installed:
|
||||
1. Java 17
|
||||
1. Java 17+
|
||||
2. Android Studio
|
||||
3. Android 8.0+ Phone or Emulation setup
|
||||
|
||||
@@ -136,7 +166,7 @@ Fork and clone this repository and import it into Android Studio
|
||||
git clone https://github.com/vitorpamplona/amethyst.git
|
||||
```
|
||||
|
||||
Use one of the Android Studio builds to install and run the app in your device or a simulator.
|
||||
Use an Android Studio build action to install and run the app on your device or a simulator.
|
||||
|
||||
## Building
|
||||
Build the app:
|
||||
@@ -168,19 +198,7 @@ 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
|
||||
## Deploying
|
||||
|
||||
1. Generate a new signing key
|
||||
```
|
||||
@@ -199,23 +217,35 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
|
||||
7. Add your CHANGE LOG to the description of the new release
|
||||
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.
|
||||
## Using the Quartz library
|
||||
|
||||
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
|
||||
Setup [JitPack.io](https://jitpack.io/#vitorpamplona/amethyst/v0.84.3) to your build file
|
||||
|
||||
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs.
|
||||
Add `maven { url 'https://jitpack.io' }` to settings.gradle at the end of repositories:
|
||||
|
||||
# DM Privacy #
|
||||
While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
|
||||
```gradle
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# Visibility & Permanence of Your Content on nostr
|
||||
## Information Visibility ##
|
||||
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.
|
||||
Add the dependency
|
||||
|
||||
## 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.
|
||||
```gradle
|
||||
implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3')
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are 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.
|
||||
|
||||
# Screenshots
|
||||
|
||||
@@ -223,7 +253,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
## Contributors
|
||||
# Contributors
|
||||
|
||||
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
|
||||
@@ -231,6 +261,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|
||||
|
||||
# MIT License
|
||||
|
||||
<pre>
|
||||
Copyright (c) 2023 Vitor Pamplona
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
@@ -250,3 +281,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
</pre>
|
||||
|
||||
134
app/build.gradle
134
app/build.gradle
@@ -1,7 +1,7 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'com.google.gms.google-services'
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
alias(libs.plugins.googleServices)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,8 +12,9 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 358
|
||||
versionName "0.84.3"
|
||||
versionCode 359
|
||||
versionName "0.85.0"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"d8da33fd13d129d86c53564aedefafbe3716f007c520431be4a8e488d3925afb\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -144,14 +145,13 @@ android {
|
||||
// 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}'
|
||||
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
|
||||
}
|
||||
exclude '**/libscrypt.dylib'
|
||||
}
|
||||
|
||||
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
@@ -164,75 +164,78 @@ 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"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.activity.compose
|
||||
|
||||
implementation platform(libs.androidx.compose.bom)
|
||||
|
||||
implementation libs.androidx.ui
|
||||
implementation libs.androidx.ui.graphics
|
||||
implementation libs.androidx.ui.tooling.preview
|
||||
|
||||
// Needs this to open gallery / image upload
|
||||
implementation "androidx.fragment:fragment-ktx:$fragment_version"
|
||||
implementation libs.androidx.fragment.ktx
|
||||
|
||||
// Navigation
|
||||
implementation "androidx.navigation:navigation-compose:$nav_version"
|
||||
implementation libs.androidx.navigation.compose
|
||||
|
||||
// Observe Live data as State
|
||||
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
|
||||
implementation libs.androidx.runtime.livedata
|
||||
|
||||
// Material 3 Design
|
||||
implementation "androidx.compose.material3:material3:${material3_version}"
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
|
||||
implementation libs.androidx.material3
|
||||
implementation libs.androidx.material.icons
|
||||
|
||||
// Adaptive Layout / Two Pane
|
||||
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
|
||||
implementation 'com.google.accompanist:accompanist-adaptive:0.34.0'
|
||||
|
||||
implementation libs.androidx.material3.windowSize
|
||||
implementation libs.accompanist.adaptive
|
||||
|
||||
// Lifecycle
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
implementation libs.androidx.lifecycle.runtime.ktx
|
||||
implementation libs.androidx.lifecycle.runtime.compose
|
||||
implementation libs.androidx.lifecycle.viewmodel.compose
|
||||
implementation libs.androidx.lifecycle.livedata.ktx
|
||||
|
||||
// Zoomable images
|
||||
implementation 'net.engawapg.lib:zoomable:1.6.0'
|
||||
implementation libs.zoomable
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
implementation libs.androidx.biometric.ktx
|
||||
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
|
||||
implementation libs.okhttp
|
||||
|
||||
// HTML Parsing for Link Preview
|
||||
implementation 'org.jsoup:jsoup:1.17.2'
|
||||
implementation libs.jsoup
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
implementation libs.androidx.security.crypto.ktx
|
||||
|
||||
// view videos
|
||||
implementation "androidx.media3:media3-exoplayer:$media3_version"
|
||||
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-session:$media3_version"
|
||||
implementation libs.androidx.media3.exoplayer
|
||||
implementation libs.androidx.media3.exoplayer.hls
|
||||
implementation libs.androidx.media3.ui
|
||||
implementation libs.androidx.media3.session
|
||||
|
||||
// important for proxy / tor
|
||||
implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
|
||||
implementation libs.androidx.media3.datasource.okhttp
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
implementation libs.coil.compose
|
||||
// view gifs
|
||||
implementation "io.coil-kt:coil-gif:$coil_version"
|
||||
implementation libs.coil.gif
|
||||
// view svgs
|
||||
implementation "io.coil-kt:coil-svg:$coil_version"
|
||||
implementation libs.coil.svg
|
||||
|
||||
// create blurhash
|
||||
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||
implementation libs.trbl.blurhash
|
||||
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
implementation libs.accompanist.permissions
|
||||
|
||||
// For QR generation
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
implementation libs.zxing
|
||||
implementation libs.zxing.embedded
|
||||
|
||||
// Markdown
|
||||
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
@@ -240,51 +243,50 @@ dependencies {
|
||||
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// Markdown (With fix for full-image bleeds)
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced')
|
||||
implementation libs.markdown.ui
|
||||
implementation libs.markdown.ui.material3
|
||||
implementation libs.markdown.commonmark
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation libs.androidx.appcompat
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
playImplementation libs.google.mlkit.language.id
|
||||
|
||||
// Google services model the translate text
|
||||
playImplementation 'com.google.mlkit:translate:17.0.2'
|
||||
playImplementation libs.google.mlkit.translate
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.7.2')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
playImplementation platform(libs.firebase.bom)
|
||||
playImplementation libs.firebase.messaging
|
||||
|
||||
//PushNotifications(FDroid)
|
||||
fdroidImplementation 'com.github.UnifiedPush:android-connector:2.2.0'
|
||||
fdroidImplementation libs.unifiedpush
|
||||
|
||||
// Charts
|
||||
implementation "com.patrykandpatrick.vico:core:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:compose:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:views:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
|
||||
implementation libs.vico.charts.core
|
||||
implementation libs.vico.charts.compose
|
||||
implementation libs.vico.charts.views
|
||||
implementation libs.vico.charts.m3
|
||||
|
||||
// GeoHash
|
||||
implementation 'com.github.drfonfon:android-kotlin-geohash:1.0'
|
||||
implementation libs.drfonfon.geohash
|
||||
|
||||
// Waveform visualizer
|
||||
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
|
||||
implementation libs.audiowaveform
|
||||
|
||||
// Video compression lib
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
|
||||
implementation libs.abedElazizShe.image.compressor
|
||||
// Image compression lib
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
implementation libs.zelory.video.compressor
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
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"
|
||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.mockk
|
||||
androidTestImplementation libs.androidx.junit
|
||||
androidTestImplementation libs.androidx.junit.ktx
|
||||
androidTestImplementation libs.androidx.espresso.core
|
||||
debugImplementation libs.androidx.ui.tooling
|
||||
debugImplementation libs.androidx.ui.test.manifest
|
||||
}
|
||||
|
||||
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.ots.OpenTimestamps
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OkHttpOtsTest {
|
||||
val otsEvent = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqCiiW0FlsU9lqK5f1A+cL6CGJ1Ah4V/A1yNJY/stUE3wECJz6ng/QxU5Z6xwaMx97qkI//AQqJv8bEMrGTplGWRv5qm4DgjxIHkcQqzpL0Fjr9VBAAijDe0IsQYpOhw1SIjZIgQa6i16CPEEZck7CvAIxR0AloJzCZoAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEOwPtjIkKI1hmtv9t1kuxZcI8QRlyTsK8Ahl0wrCSggZzgCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEE1dVGa8JCuf2ek0c5ybDKII8SCBoVz8Sal45Kd1O8STWIGJTcl5JPtAZBZitqk3BE9MqAjxBGXJOwrwCHoGVgAZi9q9AIPf4w0u+QyOKShodHRwczovL2Zpbm5leS5jYWxlbmRhci5ldGVybml0eXdhbGwuY29t8BAFZFXFYg7DJJ0OzjmJ0FKWCPEEZck7CvAI0M49IcBR5bf/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPEgoE3IfYTmxxo4W/x/QYp/NGX6Wu93gSQkwbpjpOhZORcI8SDWLLurVQaXHdUuwivCfTfuxYCaq+AzypSGqLDAVocrEgjwIBfgjta16y13Gp4etQOCa9YiKEcM+/9AieG/vZolr3IDCPAgMR2zFCb384CEi8tVuI2fHgLT3I9zpe7oqJTzCcEqxWEI8SAJSdgeeosr7IxdOt8r7f0ipWc8FI6GAhgep8zSRgWikAjxIGxYmtCsC79Tx4z4YsT1WuMo+ycMkwhGQsQltF597cchCPAgCLrBf9vR1Aex6yY+vSkXAvLjMKdMqM/a1g8zNPwLeJcI8SBDCbTk4CczTuiIyZeUyYRVh31BZdjaSd2nU/pBQxu+6QjwIOwqE9/WqGC6CHH0i+tr7edvYX5PstSDf08KmnMqsqCoCPEgQIdEBg358vfek3Qjfyrgl51iCU6WUWmThsGLPDTcB0QI8SAX4dp64iI8pBx+zBqAQwUN6XgZ1cEfT8+2vha/9I1vzwjxWQEAAAAB8YJxvxJJth8OnxIV6UOXveIZAcJPTHcAkWgnucpuYqYAAAAAAP3///8CRhoMAAAAAAAWABTUUZK82uAvbU3vVyaaPIOddZBicwAAAAAAAAAAImog8ARCqgwACAjwIB9TcMDLhzgeS1Uw647lNvCfWECkkUvfrrOe6nay0sGdCAjwICYfs90sbPggoMICyOHGYbmOzop2L9mlnh4xqiBLY7yPCAjxINDiVWOBHnRmGJleQdB9myvJAJbNJ9kciZlTOkgJy89mCAjwIHfxqDLdwycj1Vtyth2CaSDdLQwiey9oV6Xov4stLpWNCAjxIGurJYpKJnKp9+y7MAdC+gXgHOiAu5P3RRUFW9l5hGaCCAjxICXqs9hdY0QMP/MNeqlt6s7xaIYtEXZ1CLvou5gaZNEICAjxIHdYxVeI76NXbT2zHcv6lw+v819Ooib7KWxc1GAsiX2fCAjwICPWdi3uBXOlIdmYi+V9C7wAqLyGE4DMoHD+GtvLizqiCAjwIOF2vENtWN5okEMMS+JSf1SGTY9yYP9j0JjXLbC1s+N1CAjxINWsgCtsPxhRNbe372k8/20WDbiL9e8934hGF256DvRECAjwII3mi+Li06j10ORxg0dYMkcsyGb115Jiqq1YEV3K/u+aCAgABYiWDXPXGQEDw9Qy\",\"created_at\":1707690688,\"id\":\"759f9da5846e936fab06766a524b36ba71c03bbc69ad0944fb8ee4bb1f3dd705\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"07c7896c8cbb97b5d7483097590c9d31b73f35c1ad9e752002bb5c1776cbd852e1d32704333d6930c9bc3e40f8b899a1f2e9f91cc3bf797d86acdecba7792576\",\"tags\":[[\"e\",\"a828a25b4165b14f65a8ae5fd40f9c2fa086275021e15fc0d7234963fb2d504d\"],[\"p\",\"595ca8eaace5899cb6ab7e2542bfc972136376f2eabc09287f1857eb8f167e53\"],[\"alt\",\"NIP-03 time stamp\"]]}"
|
||||
val otsEvent2 = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwENyCNtiEN98IzIZgEu3cl6YI//AQ6TkSRd3BTGhDHCK1KkJc+AjxIAHaizG++NNL3Vm13BJrIhT7Br6tEYpb0TVRGaadgiUMCPAgOSDREH9v1Y50UHu79LfC4Lcd9WklQJzRQpw+Unb/pyII8QRltDD58AgqrxfAVrLw7QCD3+MNLvkMji4taHR0cHM6Ly9hbGljZS5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQQMq/CLpGwY60nmddPS7OVgjxIDKxqd9nl+Mej41vP52Wd7gv7004r3n1rFGDObS8icRvCPAgH9TB/kwvXJEEw+h9Ce6fLaI3MORjtTEge0GbAefT6W4I8QRltDD58AhRcoU3gAo/swCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wECWtWsKo0uvSr8BYonjs3DEI8CBlsh2ng1Spl0K4oStYElGuMJsjd2uo5nXB+apo5A7ipwjxIM8oxynBwNA+QS/X7Ebtl1kyhFgfoOQioASNfCBzZ4gaCPEEZbQw+fAId6Yd5cw5gioAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb23wEJmPzXQbxv0AFTIyjTWjMskI8CAurbkrfrBtlinZXSDxj+m/oIkze57hGjTSxu1Xs87XYQjwIPk/LMD0zIgKoEE2dfeoYrrdHuO6dwmghTwUFajH2QzkCPEEZbQw+fAIE+Pq1/Wmdpj/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPAgB2CbqkV7VpjRKIl3Ea6cBmB/EHcSN/YCgcc1E+mc07QI8CAfpkZ2Hh4Rukz3x4il3tZqQtlDlbna+I6so2t2YSEmMQjwIJOv32jbsMa2HJwpleRCKLEhgYOoHCSfpv1ZO0YNNNFsCPAgLMM7eFfCjokQfU4gdU5WpG/wBLkO9lDRF0GktL6ujt8I8SCRxJ0bC1PQ8qFmI/1jh8AS5d1/6VRJNMt1Hz41QmNr3QjwIBmgrKBF+OZ3y+XOMv2E7IZ4WwLr2u2H+ehsBfy7cPlICPEgm4ZMCSXzZVWu40d+zk2edaur6KOauo8X7V2KaFBR1VoI8SBKVVOiyq6IFqGn/15kLwk7L8upMAIZ0znjhYxYqSTQCQjwIMBD1twPZ33GxbwTiuOCeJPkoP++6R2wYpCii8UBTdgwCPAg84VkgMXwrt2xxRoeC1/6CtsFctki+w3m8Rs5/6g/IhEI8CCnzZQDhJyicX7bS7U8PMUObuC9Y4TXe+4THoXBMMXkxwjxIMZ0oAvshpcwowR3qPEDbwKZ6B4NPSU4Hz/+4PnD74gnCPAgIfLZEKqAvkMNXfakXoNq1UVqGSzL4Z86z5GzUfbvw8UI8SC8KoIeLvjd4vJ/xhNVphakPRd80YKeNkeYEuVH8k2EtAjwIPRtinLLxzt8iuw0XZtpTDzEstZOTNYVm+Bi3fEzdeIuCPFZAQAAAAE8vsasINN5DKon0KakX2HNdCB126ZLKXrw4PfvyEfqbwAAAAAA/f///wLPGw8AAAAAABYAFCvgxlh6msa4ZOtgvlc5KiZCx7IvAAAAAAAAAAAiaiDwBKygDAAICPEgB/2DJ3s6gMky/PceGZocTFRXjZUiCCAhHGYwQk/8yrUICPEgDuSd6+PJHUMuEHyLcKFxw7xfvRHRfInjkV3/Zy3BxqAICPEgZDgQ+4VXzlOIkGoO8EVxDgs2cWaeh4EEiaqa/y50gKAICPAgFI+zIuYcMF69GmPQVsXa9oy8eng7MeRZdIxArQyeX3oICPEgX5HYIuImpiSTEapgEssEW4l+W+4aRfNCG3pZf7z0hCoICPEgPkAbOSjFdtS4NT7MXgMYVQoQhI1JZtdFxUu4J3NTt7IICPEguW3qyuyGjctu5d9rM9P9ZCs/ZK4vAc+z21b9ygklgWAICPEgu4e2645xtvGhI1Zzuiv23vRhwE8uC9vj1TAgNg/C8UcICPAgwoQX8X0nY4HoQLRsJ0z8JCWQDzRh2iL2QXEb8z3gbjIICPAgzTwlPRtStsLJWhz3Q/0l8tMnrPSHVuh+zCiGk95dW2MICPAgGcBEuYZyzFNapHOfnJ9Q515QzO2VbIRhlVI0vIhd4jwICPAgidRMoM2pA+KmVJenVrLcbollsbUg9lL9bmv1C1dSxswICPAgZinGakwhbHdanTaRJeBkEUlbhfNokvj8b5KneyG+wzIICAAFiJYNc9cZAQOtwTI=\",\"created_at\":1706324334,\"id\":\"2ad074ddb7724eb13b4244b49cf2321b1057f37fdf8ce102e6329b839cf763a9\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"ad7274bb32ba9e9cfdbd52f4887e8a2fda1047c75a7185b2ab7ff254ebac14ed48a2b60737494d655e24c9400eeeec7e29293a77bfcaafaecd94b350c9a2c22b\",\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"p\",\"c31e22c3715c1bde5608b7e0d04904f22f5fc453ba1806d21c9f2382e1e58c6c\"],[\"alt\",\"NIP-03 time stamp\"]]}"
|
||||
val otsPendingEvent = "{\"id\":\"12fa15ad4b4cf9dc5940389325b69b93c5c1f59c049c701ee669b275299fdaf1\",\"pubkey\":\"dcaa6c8a2f47b6fef4a34b20e8843c59dbe7c5f07a402338c09fd147dd01d22b\",\"created_at\":1708877521,\"kind\":1040,\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"alt\",\"Opentimestamps Attestation\"]],\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwELidvzr0usf55CkpKf6OABQI//AQK3sWd2tq+7KO8YNJIARJugjxBGXbZtPwCL0H4/7GL5+SAIPf4w0u+QyOLCtodHRwczovL2JvYi5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQPDZsJgN1TnJXoUzlsgo93wjwIIfBc7LUqkCbC1BLZRZ+6LXztK50UdH5xe7fn40bupkrCPEEZdtm0/AI0CADXN5ZIncAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ/AQcELcSrE04cuGKlZQf2LeVwjwILUDSf9vK2GaefKTpn/LV2oUsQaA5WbqaP3C+1ZxQfRNCPEEZdtm0/AIbCtb+yRXFqUAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb20=\",\"sig\":\"f6854c0228c15c08aeb70bbabe9ed87bbb7289fab31b13cabac15138bb71179553e06080b83f4a813fbdaf614f63293beea3fc73fe865da6551193fa4d38de04\"}"
|
||||
|
||||
val otsEvent2Digest = "a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyNostrEvent() {
|
||||
val ots = Event.fromJson(otsEvent) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(1707688818L, ots.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyNostrEvent2() {
|
||||
val ots = Event.fromJson(otsEvent2) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(1706322179L, ots.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyNostrPendingEvent() {
|
||||
val ots = Event.fromJson(otsPendingEvent) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(null, ots.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createOTSEventAndVerify() {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
var ots: OtsEvent? = null
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
|
||||
val otsFile = OtsEvent.stamp(otsEvent2Digest)
|
||||
|
||||
OtsEvent.create(otsEvent2Digest, otsFile, signer) {
|
||||
ots = it
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
|
||||
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
|
||||
|
||||
println(ots!!.toJson())
|
||||
println(ots!!.info())
|
||||
|
||||
// Should not be valid because we need to wait for confirmations
|
||||
assertEquals(null, ots!!.verify())
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,11 @@ import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.ots.OpenTimestamps
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -53,7 +57,7 @@ class Amethyst : Application() {
|
||||
newCache
|
||||
}
|
||||
|
||||
private val imageCache: DiskCache by lazy {
|
||||
val coilCache: DiskCache by lazy {
|
||||
DiskCache.Builder()
|
||||
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.2)
|
||||
@@ -65,6 +69,8 @@ class Amethyst : Application() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy.Builder().detectAll().penaltyLog().build(),
|
||||
@@ -85,7 +91,7 @@ class Amethyst : Application() {
|
||||
}
|
||||
|
||||
fun imageLoaderBuilder(): ImageLoader.Builder {
|
||||
return ImageLoader.Builder(applicationContext).diskCache { imageCache }
|
||||
return ImageLoader.Builder(applicationContext).diskCache { coilCache }
|
||||
}
|
||||
|
||||
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {
|
||||
|
||||
@@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.model.DefaultReactions
|
||||
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
@@ -44,6 +43,8 @@ import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
@@ -112,6 +113,8 @@ private object PrefKeys {
|
||||
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
|
||||
const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture"
|
||||
const val SIGNER_PACKAGE_NAME = "signer_package_name"
|
||||
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
|
||||
const val PENDING_ATTESTATIONS = "pending_attestations"
|
||||
|
||||
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
@@ -331,12 +334,18 @@ object LocalPreferences {
|
||||
PrefKeys.LAST_READ_PER_ROUTE,
|
||||
Event.mapper.writeValueAsString(account.lastReadPerRoute),
|
||||
)
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
|
||||
|
||||
if (account.showSensitiveContent == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.PENDING_ATTESTATIONS,
|
||||
Event.mapper.writeValueAsString(account.pendingAttestations),
|
||||
)
|
||||
}
|
||||
.apply()
|
||||
}
|
||||
@@ -521,7 +530,7 @@ object LocalPreferences {
|
||||
val zapPaymentRequestServer =
|
||||
try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
Event.mapper.readValue<Nip47URI?>(it)
|
||||
Event.mapper.readValue<Nip47WalletConnect.Nip47URI?>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -554,6 +563,26 @@ object LocalPreferences {
|
||||
null
|
||||
}
|
||||
|
||||
val pendingAttestations =
|
||||
try {
|
||||
getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let {
|
||||
println("Decoding Attestation List: " + it)
|
||||
if (it != null) {
|
||||
Event.mapper.readValue<Map<HexKey, String>>(it)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences =
|
||||
try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
@@ -617,6 +646,8 @@ object LocalPreferences {
|
||||
NostrSignerInternal(keyPair)
|
||||
}
|
||||
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
|
||||
val account =
|
||||
Account(
|
||||
keyPair = keyPair,
|
||||
@@ -644,6 +675,8 @@ object LocalPreferences {
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
hasDonatedInVersion = hasDonatedInVersion,
|
||||
pendingAttestations = pendingAttestations ?: emptyMap(),
|
||||
)
|
||||
|
||||
// Loads from DB
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.switchMap
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||
@@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
@@ -65,6 +67,7 @@ import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GeneralListEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -74,6 +77,7 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NIP24Factory
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
@@ -86,6 +90,7 @@ import com.vitorpamplona.quartz.events.Response
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.WrappedEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
@@ -96,6 +101,7 @@ import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -170,7 +176,7 @@ class Account(
|
||||
var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP24WarningDialog: Boolean = false,
|
||||
@@ -181,10 +187,10 @@ class Account(
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
|
||||
var hasDonatedInVersion: Set<String> = setOf<String>(),
|
||||
var pendingAttestations: Map<HexKey, String> = mapOf<HexKey, String>(),
|
||||
val scope: CoroutineScope = Amethyst.instance.applicationIOScope,
|
||||
) {
|
||||
// Uses a single scope for the entire application.
|
||||
val scope = Amethyst.instance.applicationIOScope
|
||||
|
||||
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
|
||||
|
||||
data class PaymentRequest(
|
||||
@@ -727,7 +733,7 @@ class Account(
|
||||
zapResponseEvent.response(signer, onReady)
|
||||
}
|
||||
|
||||
fun calculateIfNoteWasZappedByAccount(
|
||||
suspend fun calculateIfNoteWasZappedByAccount(
|
||||
zappedNote: Note?,
|
||||
onWasZapped: () -> Unit,
|
||||
) {
|
||||
@@ -891,6 +897,38 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateAttestations() {
|
||||
Log.d("Pending Attestations", "Updating ${pendingAttestations.size} pending attestations")
|
||||
|
||||
pendingAttestations.toMap().forEach { pair ->
|
||||
val newAttestation = OtsEvent.upgrade(pair.value, pair.key)
|
||||
|
||||
if (pair.value != newAttestation) {
|
||||
OtsEvent.create(pair.key, newAttestation, signer) {
|
||||
LocalCache.justConsume(it, null)
|
||||
Client.send(it)
|
||||
|
||||
pendingAttestations = pendingAttestations - pair.key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasPendingAttestations(note: Note): Boolean {
|
||||
val id = note.event?.id() ?: note.idHex
|
||||
return pendingAttestations.get(id) != null
|
||||
}
|
||||
|
||||
fun timestamp(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val id = note.event?.id() ?: note.idHex
|
||||
|
||||
pendingAttestations = pendingAttestations + Pair(id, OtsEvent.stamp(id))
|
||||
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun follow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1166,7 +1204,7 @@ class Account(
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
return LocalCache.getNoteIfExists(signedEvent.id)
|
||||
}
|
||||
|
||||
fun consumeNip95(
|
||||
@@ -1176,7 +1214,7 @@ class Account(
|
||||
LocalCache.consume(data, null)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
return LocalCache.getNoteIfExists(signedEvent.id)
|
||||
}
|
||||
|
||||
fun sendNip95(
|
||||
@@ -1196,7 +1234,7 @@ class Account(
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
LocalCache.notes[signedEvent.id]?.let { onReady(it) }
|
||||
LocalCache.getNoteIfExists(signedEvent.id)?.let { onReady(it) }
|
||||
}
|
||||
|
||||
fun createHeader(
|
||||
@@ -1312,6 +1350,63 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendGitReply(
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
mentions: List<User>?,
|
||||
repository: ATag?,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
replyingTo: String?,
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
forkedFrom: Event?,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
val addresses = listOfNotNull(repository) + (replyTo?.mapNotNull { it.address() } ?: emptyList())
|
||||
|
||||
GitReplyEvent.create(
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
addresses = addresses,
|
||||
extraTags = null,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
replyingTo = replyingTo,
|
||||
root = root,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
forkedFrom = forkedFrom,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
// broadcast replied notes
|
||||
replyingTo?.let {
|
||||
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
@@ -1323,6 +1418,7 @@ class Account(
|
||||
replyingTo: String?,
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
forkedFrom: Event?,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
@@ -1347,6 +1443,7 @@ class Account(
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
forkedFrom = forkedFrom,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
@@ -1367,6 +1464,29 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendEdit(
|
||||
message: String,
|
||||
originalNote: Note,
|
||||
notify: HexKey?,
|
||||
summary: String? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val idHex = originalNote.event?.id() ?: return
|
||||
|
||||
TextNoteModificationEvent.create(
|
||||
content = message,
|
||||
eventId = idHex,
|
||||
notify = notify,
|
||||
summary = summary,
|
||||
signer = signer,
|
||||
) {
|
||||
LocalCache.justConsume(it, null)
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPoll(
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
@@ -2037,7 +2157,7 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47URI?) {
|
||||
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
|
||||
zapPaymentRequest = newServer
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
@@ -2354,6 +2474,16 @@ class Account(
|
||||
return lastReadPerRoute[route] ?: 0
|
||||
}
|
||||
|
||||
fun hasDonatedInThisVersion(): Boolean {
|
||||
return hasDonatedInVersion.contains(BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
fun markDonatedInThisVersion() {
|
||||
hasDonatedInVersion = hasDonatedInVersion + BuildConfig.VERSION_NAME
|
||||
saveable.invalidateData()
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
suspend fun registerObservers() =
|
||||
withContext(Dispatchers.Main) {
|
||||
// saves contact list for the next time.
|
||||
|
||||
@@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
@@ -58,7 +58,7 @@ class AntiSpamFilter {
|
||||
// really long, make it ok.
|
||||
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still
|
||||
// blocking repeated "lnbc..." invoices or fishing urls
|
||||
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false
|
||||
if (event.content.length < 180 && Nip19Bech32.nip19regex.matcher(event.content).find()) return false
|
||||
|
||||
// double list strategy:
|
||||
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
|
||||
@@ -71,7 +71,7 @@ class AntiSpamFilter {
|
||||
) {
|
||||
Log.w(
|
||||
"Potential SPAM Message for sharing",
|
||||
"${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}",
|
||||
"${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, null)}",
|
||||
)
|
||||
Log.w(
|
||||
"Potential SPAM Message",
|
||||
|
||||
@@ -21,18 +21,17 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.HexValidator
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.decodeEventIdAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
@@ -65,12 +64,17 @@ import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FhirResourceEvent
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
@@ -82,6 +86,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NNSEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
@@ -94,8 +99,11 @@ import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -112,18 +120,31 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
object LocalCache {
|
||||
val antiSpam = AntiSpamFilter()
|
||||
|
||||
val users = ConcurrentHashMap<HexKey, User>(5000)
|
||||
val notes = ConcurrentHashMap<HexKey, Note>(5000)
|
||||
private val users = ConcurrentHashMap<HexKey, User>(5000)
|
||||
private val notes = ConcurrentHashMap<HexKey, Note>(5000)
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
|
||||
|
||||
val awaitingPaymentRequests =
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
var noteListCache: List<Note> = emptyList()
|
||||
var userListCache: List<User> = emptyList()
|
||||
|
||||
fun updateListCache() {
|
||||
val (value, elapsed) =
|
||||
measureTimedValue {
|
||||
noteListCache = ArrayList(notes.values)
|
||||
userListCache = ArrayList(users.values)
|
||||
}
|
||||
Log.d("LocalCache", "UpdateListCache $elapsed")
|
||||
}
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
// checkNotInMainThread()
|
||||
|
||||
@@ -360,6 +381,94 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: GitPatchEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: GitIssueEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: GitReplyEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
val repository = event.repository()?.toTag()
|
||||
|
||||
val replyTo =
|
||||
event
|
||||
.tagsWithoutCitations()
|
||||
.filter { it != repository }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
// println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}")
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LongTextNoteEvent,
|
||||
relay: Relay?,
|
||||
@@ -395,6 +504,41 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: WikiNoteEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let { it.spamCounter++ }
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: PollNoteEvent,
|
||||
relay: Relay? = null,
|
||||
@@ -472,6 +616,13 @@ object LocalCache {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: GitRepositoryEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: ChannelListEvent,
|
||||
relay: Relay?,
|
||||
@@ -588,6 +739,30 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: OtsEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (version.event?.id() == event.id()) return
|
||||
|
||||
// makes sure the OTS has a valid certificate
|
||||
if (event.cacheVerify() == null) return // no valid OTS
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
if (version.liveSet != null) {
|
||||
updateListCache()
|
||||
}
|
||||
version.liveSet?.innerOts?.invalidateData()
|
||||
}
|
||||
|
||||
refreshObservers(version)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: BadgeDefinitionEvent,
|
||||
relay: Relay?,
|
||||
@@ -759,7 +934,7 @@ object LocalCache {
|
||||
|
||||
event
|
||||
.deleteEvents()
|
||||
.mapNotNull { notes[it] }
|
||||
.mapNotNull { getNoteIfExists(it) }
|
||||
.forEach { deleteNote ->
|
||||
// must be the same author
|
||||
if (deleteNote.author?.pubkeyHex == event.pubKey) {
|
||||
@@ -1200,6 +1375,57 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: FhirResourceEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: TextNoteModificationEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
event.editedNote()?.let {
|
||||
checkGetOrCreateNote(it)?.let { editedNote ->
|
||||
modificationCache.remove(editedNote.idHex)
|
||||
// must update list of Notes to quickly update the user.
|
||||
if (editedNote.liveSet != null) {
|
||||
updateListCache()
|
||||
}
|
||||
editedNote.liveSet?.innerModifications?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: HighlightEvent,
|
||||
relay: Relay?,
|
||||
@@ -1396,11 +1622,14 @@ object LocalCache {
|
||||
|
||||
val key = decodePublicKeyAsHexOrNull(username)
|
||||
|
||||
if (key != null && users[key] != null) {
|
||||
return listOfNotNull(users[key])
|
||||
if (key != null) {
|
||||
val user = getUserIfExists(key)
|
||||
if (user != null) {
|
||||
return listOfNotNull(user)
|
||||
}
|
||||
}
|
||||
|
||||
return users.values.filter {
|
||||
return userListCache.filter {
|
||||
(it.anyNameStartsWith(username)) ||
|
||||
it.pubkeyHex.startsWith(username, true) ||
|
||||
it.pubkeyNpub().startsWith(username, true)
|
||||
@@ -1410,19 +1639,16 @@ object LocalCache {
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key =
|
||||
try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
val key = decodeEventIdAsHexOrNull(text)
|
||||
|
||||
if (key != null && (notes[key] ?: addressables[key]) != null) {
|
||||
return listOfNotNull(notes[key] ?: addressables[key])
|
||||
if (key != null) {
|
||||
val note = getNoteIfExists(key)
|
||||
if (note != null) {
|
||||
return listOfNotNull(note)
|
||||
}
|
||||
}
|
||||
|
||||
return notes.values.filter {
|
||||
return noteListCache.filter {
|
||||
(
|
||||
it.event !is GenericRepostEvent &&
|
||||
it.event !is RepostEvent &&
|
||||
@@ -1460,14 +1686,7 @@ object LocalCache {
|
||||
fun findChannelsStartingWith(text: String): List<Channel> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key =
|
||||
try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
val key = decodeEventIdAsHexOrNull(text)
|
||||
if (key != null && channels[key] != null) {
|
||||
return listOfNotNull(channels[key])
|
||||
}
|
||||
@@ -1498,12 +1717,61 @@ object LocalCache {
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
suspend fun findEarliestOtsForNote(note: Note): Long? {
|
||||
checkNotInMainThread()
|
||||
|
||||
var minTime: Long? = null
|
||||
val time = TimeUtils.now()
|
||||
|
||||
noteListCache.forEach { item ->
|
||||
val noteEvent = item.event
|
||||
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
|
||||
noteEvent.verifiedTime?.let { stampedTime ->
|
||||
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
|
||||
minTime = stampedTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minTime
|
||||
}
|
||||
|
||||
val modificationCache = LruCache<HexKey, List<Note>>(20)
|
||||
|
||||
fun cachedModificationEventsForNote(note: Note): List<Note>? {
|
||||
return modificationCache[note.idHex]
|
||||
}
|
||||
|
||||
suspend fun findLatestModificationForNote(note: Note): List<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val originalAuthor = note.author?.pubkeyHex ?: return emptyList()
|
||||
|
||||
modificationCache[note.idHex]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val time = TimeUtils.now()
|
||||
|
||||
val newNotes =
|
||||
noteListCache.filter { item ->
|
||||
val noteEvent = item.event
|
||||
|
||||
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
|
||||
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
|
||||
modificationCache.put(note.idHex, newNotes)
|
||||
|
||||
return newNotes
|
||||
}
|
||||
|
||||
fun cleanObservers() {
|
||||
notes.forEach { it.value.clearLive() }
|
||||
noteListCache.forEach { it.clearLive() }
|
||||
|
||||
addressables.forEach { it.value.clearLive() }
|
||||
|
||||
users.forEach { it.value.clearLive() }
|
||||
userListCache.forEach { it.clearLive() }
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
@@ -1529,8 +1797,8 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
users.forEach { userPair ->
|
||||
userPair.value.privateChatrooms.values.map {
|
||||
userListCache.forEach { userPair ->
|
||||
userPair.privateChatrooms.values.map {
|
||||
val toBeRemoved = it.pruneMessagesToTheLatestOnly()
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
@@ -1545,7 +1813,7 @@ object LocalCache {
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept",
|
||||
"PRUNE: ${toBeRemoved.size} private messages with ${userPair.toBestDisplayName()} removed. ${it.roomMessages.size} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1554,9 +1822,9 @@ object LocalCache {
|
||||
|
||||
fun prunePastVersionsOfReplaceables() {
|
||||
val toBeRemoved =
|
||||
notes
|
||||
noteListCache
|
||||
.filter {
|
||||
val noteEvent = it.value.event
|
||||
val noteEvent = it.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
noteEvent.createdAt() <
|
||||
(addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0)
|
||||
@@ -1564,7 +1832,6 @@ object LocalCache {
|
||||
false
|
||||
}
|
||||
}
|
||||
.values
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
@@ -1589,24 +1856,23 @@ object LocalCache {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved =
|
||||
notes
|
||||
noteListCache
|
||||
.filter {
|
||||
(
|
||||
(it.value.event is TextNoteEvent && !it.value.isNewThread()) ||
|
||||
it.value.event is ReactionEvent ||
|
||||
it.value.event is LnZapEvent ||
|
||||
it.value.event is LnZapRequestEvent ||
|
||||
it.value.event is ReportEvent ||
|
||||
it.value.event is GenericRepostEvent
|
||||
(it.event is TextNoteEvent && !it.isNewThread()) ||
|
||||
it.event is ReactionEvent ||
|
||||
it.event is LnZapEvent ||
|
||||
it.event is LnZapRequestEvent ||
|
||||
it.event is ReportEvent ||
|
||||
it.event is GenericRepostEvent
|
||||
) &&
|
||||
it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
|
||||
it.value.liveSet?.isInUse() != true && // don't delete if observing.
|
||||
it.value.author?.pubkeyHex !in
|
||||
it.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
|
||||
it.liveSet?.isInUse() != true && // don't delete if observing.
|
||||
it.author?.pubkeyHex !in
|
||||
accounts && // don't delete if it is the logged in account
|
||||
it.value.event?.isTaggedUsers(accounts) !=
|
||||
it.event?.isTaggedUsers(accounts) !=
|
||||
true // don't delete if it's a notification to the logged in user
|
||||
}
|
||||
.values
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
@@ -1672,7 +1938,8 @@ object LocalCache {
|
||||
fun pruneExpiredEvents() {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved = notes.filter { it.value.event?.isExpired() == true }.values
|
||||
val now = TimeUtils.now()
|
||||
val toBeRemoved = noteListCache.filter { it.event?.isExpirationBefore(now) == true }
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
@@ -1698,7 +1965,7 @@ object LocalCache {
|
||||
?.hiddenUsers
|
||||
?.map { userHex ->
|
||||
(
|
||||
notes.values.filter { it.event?.pubKey() == userHex } +
|
||||
noteListCache.filter { it.event?.pubKey() == userHex } +
|
||||
addressables.values.filter { it.event?.pubKey() == userHex }
|
||||
)
|
||||
.toSet()
|
||||
@@ -1720,7 +1987,7 @@ object LocalCache {
|
||||
checkNotInMainThread()
|
||||
|
||||
var removingContactList = 0
|
||||
users.values.forEach {
|
||||
userListCache.forEach {
|
||||
if (
|
||||
it.pubkeyHex !in loggedIn &&
|
||||
(it.liveSet == null || it.liveSet?.isInUse() == false) &&
|
||||
@@ -1806,11 +2073,16 @@ object LocalCache {
|
||||
is EmojiPackEvent -> consume(event, relay)
|
||||
is EmojiPackSelectionEvent -> consume(event, relay)
|
||||
is SealedGossipEvent -> consume(event, relay)
|
||||
is FhirResourceEvent -> consume(event, relay)
|
||||
is FileHeaderEvent -> consume(event, relay)
|
||||
is FileServersEvent -> consume(event, relay)
|
||||
is FileStorageEvent -> consume(event, relay)
|
||||
is FileStorageHeaderEvent -> consume(event, relay)
|
||||
is GiftWrapEvent -> consume(event, relay)
|
||||
is GitIssueEvent -> consume(event, relay)
|
||||
is GitReplyEvent -> consume(event, relay)
|
||||
is GitPatchEvent -> consume(event, relay)
|
||||
is GitRepositoryEvent -> consume(event, relay)
|
||||
is HighlightEvent -> consume(event, relay)
|
||||
is LiveActivitiesEvent -> consume(event, relay)
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay)
|
||||
@@ -1828,6 +2100,7 @@ object LocalCache {
|
||||
is MetadataEvent -> consume(event)
|
||||
is MuteListEvent -> consume(event, relay)
|
||||
is NNSEvent -> comsume(event, relay)
|
||||
is OtsEvent -> consume(event, relay)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event, relay)
|
||||
is PeopleListEvent -> consume(event, relay)
|
||||
@@ -1846,8 +2119,10 @@ object LocalCache {
|
||||
}
|
||||
is StatusEvent -> consume(event, relay)
|
||||
is TextNoteEvent -> consume(event, relay)
|
||||
is TextNoteModificationEvent -> consume(event, relay)
|
||||
is VideoHorizontalEvent -> consume(event, relay)
|
||||
is VideoVerticalEvent -> consume(event, relay)
|
||||
is WikiNoteEvent -> consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
@@ -1868,6 +2143,10 @@ class LocalCacheLiveData {
|
||||
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) }
|
||||
bundler.invalidateList(newNote) {
|
||||
bundledNewNotes ->
|
||||
LocalCache.updateListCache()
|
||||
_newEventBundles.emit(bundledNewNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
@@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
@@ -66,10 +67,13 @@ import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@Stable
|
||||
class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
@@ -138,17 +142,17 @@ open class Note(val idHex: String) {
|
||||
return if (myEvent is WrappedEvent) {
|
||||
val host = myEvent.host
|
||||
if (host != null) {
|
||||
Nip19.createNEvent(
|
||||
Nip19Bech32.createNEvent(
|
||||
host.id,
|
||||
host.pubKey,
|
||||
host.kind(),
|
||||
relays.firstOrNull()?.url,
|
||||
)
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
}
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,7 +525,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun recursiveIsZappedByCalculation(
|
||||
private suspend fun isZappedByCalculation(
|
||||
option: Int?,
|
||||
user: User,
|
||||
account: Account,
|
||||
@@ -532,48 +536,65 @@ open class Note(val idHex: String) {
|
||||
return
|
||||
}
|
||||
|
||||
val next = remainingZapEvents.first()
|
||||
remainingZapEvents.forEach { next ->
|
||||
val zapRequest = next.first.event as LnZapRequestEvent
|
||||
val zapEvent = next.second?.event as? LnZapEvent
|
||||
|
||||
if (next.first.author?.pubkeyHex == user.pubkeyHex) {
|
||||
onWasZappedByAuthor()
|
||||
} else {
|
||||
account.decryptZapContentAuthor(next.first) {
|
||||
if (
|
||||
it.pubKey == user.pubkeyHex &&
|
||||
(option == null || option == (it as? LnZapEvent)?.zappedPollOption())
|
||||
) {
|
||||
if (!zapRequest.isPrivateZap()) {
|
||||
// public events
|
||||
if (zapRequest.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
|
||||
onWasZappedByAuthor()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// private events
|
||||
|
||||
// if has already decrypted
|
||||
val privateZap = zapRequest.cachedPrivateZap()
|
||||
if (privateZap != null) {
|
||||
if (privateZap.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
|
||||
onWasZappedByAuthor()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
recursiveIsZappedByCalculation(
|
||||
option,
|
||||
user,
|
||||
account,
|
||||
remainingZapEvents.minus(next),
|
||||
onWasZappedByAuthor,
|
||||
)
|
||||
if (account.isWriteable()) {
|
||||
val result =
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
zapRequest.decryptPrivateZap(account.signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result?.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
|
||||
onWasZappedByAuthor()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(
|
||||
suspend fun isZappedBy(
|
||||
user: User,
|
||||
account: Account,
|
||||
onWasZappedByAuthor: () -> Unit,
|
||||
) {
|
||||
recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
|
||||
isZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
|
||||
if (account.userProfile() == user) {
|
||||
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(
|
||||
suspend fun isZappedBy(
|
||||
option: Int?,
|
||||
user: User,
|
||||
account: Account,
|
||||
onWasZappedByAuthor: () -> Unit,
|
||||
) {
|
||||
recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
|
||||
isZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
|
||||
}
|
||||
|
||||
fun getReactionBy(user: User): String? {
|
||||
@@ -926,6 +947,8 @@ class NoteLiveSet(u: Note) {
|
||||
val innerReports = NoteBundledRefresherLiveData(u)
|
||||
val innerRelays = NoteBundledRefresherLiveData(u)
|
||||
val innerZaps = NoteBundledRefresherLiveData(u)
|
||||
val innerOts = NoteBundledRefresherLiveData(u)
|
||||
val innerModifications = NoteBundledRefresherLiveData(u)
|
||||
|
||||
val metadata = innerMetadata.map { it }
|
||||
val reactions = innerReactions.map { it }
|
||||
@@ -979,7 +1002,9 @@ class NoteLiveSet(u: Note) {
|
||||
hasReactions.hasObservers() ||
|
||||
replyCount.hasObservers() ||
|
||||
reactionCount.hasObservers() ||
|
||||
boostCount.hasObservers()
|
||||
boostCount.hasObservers() ||
|
||||
innerOts.hasObservers() ||
|
||||
innerModifications.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -990,6 +1015,8 @@ class NoteLiveSet(u: Note) {
|
||||
innerReports.destroy()
|
||||
innerRelays.destroy()
|
||||
innerZaps.destroy()
|
||||
innerOts.destroy()
|
||||
innerModifications.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,10 +139,10 @@ class User(val pubkeyHex: String) {
|
||||
// Update Followers of the past user list
|
||||
// Update Followers of the new contact list
|
||||
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
|
||||
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
|
||||
}
|
||||
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
|
||||
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
|
||||
}
|
||||
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
@@ -363,7 +363,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
suspend fun transientFollowerCount(): Int {
|
||||
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun cachedFollowingKeySet(): Set<HexKey> {
|
||||
@@ -387,7 +387,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
suspend fun cachedFollowerCount(): Int {
|
||||
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
|
||||
|
||||
@@ -20,48 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import android.util.Patterns
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.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.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.RichTextViewerState
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
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
|
||||
data class RichTextViewerState(
|
||||
val urlSet: ImmutableSet<String>,
|
||||
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
||||
val imageList: ImmutableList<ZoomableUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ParagraphState>,
|
||||
)
|
||||
|
||||
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(50)
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
@@ -76,298 +41,3 @@ object CachedRichTextParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
// val noProtocolUrlValidator =
|
||||
// Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
|
||||
// Android9 seems to have an issue starting this regex.
|
||||
val noProtocolUrlValidator =
|
||||
try {
|
||||
Pattern.compile(
|
||||
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Pattern.compile(
|
||||
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)",
|
||||
)
|
||||
}
|
||||
|
||||
val HTTPRegex =
|
||||
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
|
||||
.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
class RichTextParser() {
|
||||
fun parseMediaUrl(
|
||||
fullUrl: String,
|
||||
eventTags: ImmutableListOfLists<String>,
|
||||
): ZoomableUrlContent? {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
val frags = Nip54().parse(fullUrl)
|
||||
val tags = Nip92().parse(fullUrl, eventTags.lists)
|
||||
|
||||
ZoomableUrlImage(
|
||||
url = fullUrl,
|
||||
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 = Nip54().parse(fullUrl)
|
||||
val tags = Nip92().parse(fullUrl, eventTags.lists)
|
||||
ZoomableUrlVideo(
|
||||
url = fullUrl,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): RichTextViewerState {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
|
||||
val urlSet =
|
||||
urls.mapNotNullTo(LinkedHashSet(urls.size)) {
|
||||
// removes e-mails
|
||||
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
|
||||
null
|
||||
} else if (isNumber(it.originalUrl)) {
|
||||
null
|
||||
} else if (it.originalUrl.contains("。")) {
|
||||
null
|
||||
} else {
|
||||
if (HTTPRegex.matches(it.originalUrl)) {
|
||||
it.originalUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val imagesForPager =
|
||||
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
val emojiMap =
|
||||
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
|
||||
|
||||
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
|
||||
|
||||
return RichTextViewerState(
|
||||
urlSet.toImmutableSet(),
|
||||
imagesForPager.toImmutableMap(),
|
||||
imageList.toImmutableList(),
|
||||
emojiMap.toImmutableMap(),
|
||||
segments,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findTextSegments(
|
||||
content: String,
|
||||
images: Set<String>,
|
||||
urls: Set<String>,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): ImmutableList<ParagraphState> {
|
||||
val lines = content.split('\n')
|
||||
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
|
||||
|
||||
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.add(wordSegment)
|
||||
}
|
||||
|
||||
val newSegments =
|
||||
if (isDirty) {
|
||||
ParagraphState(segments.toPersistentList(), isRTL)
|
||||
} else {
|
||||
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
|
||||
}
|
||||
|
||||
paragraphSegments.add(newSegments)
|
||||
}
|
||||
|
||||
return paragraphSegments.toImmutableList()
|
||||
}
|
||||
|
||||
fun isNumber(word: String): Boolean {
|
||||
return numberPattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
fun isDate(word: String): Boolean {
|
||||
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
private fun isArabic(text: String): Boolean {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
private fun wordIdentifier(
|
||||
word: String,
|
||||
images: Set<String>,
|
||||
urls: Set<String>,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Segment {
|
||||
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
|
||||
val phoneMatcher = Patterns.PHONE.matcher(word)
|
||||
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
|
||||
|
||||
return if (word.isEmpty()) {
|
||||
RegularTextSegment(word)
|
||||
} else if (images.contains(word)) {
|
||||
ImageSegment(word)
|
||||
} else if (urls.contains(word)) {
|
||||
LinkSegment(word)
|
||||
} else if (emojis.any { word.contains(it.key) }) {
|
||||
EmojiSegment(word)
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
InvoiceSegment(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
WithdrawSegment(word)
|
||||
} else if (word.startsWith("cashuA", true)) {
|
||||
CashuSegment(word)
|
||||
} else if (emailMatcher.matches()) {
|
||||
EmailSegment(word)
|
||||
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
|
||||
PhoneSegment(word)
|
||||
} else if (startsWithNIP19Scheme(word)) {
|
||||
BechSegment(word)
|
||||
} else if (word.startsWith("#")) {
|
||||
parseHash(word, tags)
|
||||
} else if (word.contains(".") && schemelessMatcher.find()) {
|
||||
val url = schemelessMatcher.group(1) // url
|
||||
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)
|
||||
if (pattern.find(word) != null) {
|
||||
SchemelessUrlSegment(word, url, additionalChars)
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHash(
|
||||
word: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Segment {
|
||||
// First #[n]
|
||||
|
||||
val matcher = tagIndex.matcher(word)
|
||||
try {
|
||||
if (matcher.find()) {
|
||||
val index = matcher.group(1)?.toInt()
|
||||
val suffix = matcher.group(2)
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
return HashIndexUserSegment(word, tag[1], suffix)
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
return HashIndexEventSegment(word, tag[1], suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
}
|
||||
|
||||
// Second #Amethyst
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
try {
|
||||
if (hashtagMatcher.find()) {
|
||||
val hashtag = hashtagMatcher.group(1)
|
||||
if (hashtag != null) {
|
||||
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)
|
||||
}
|
||||
|
||||
return RegularTextSegment(word)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
|
||||
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable open class Segment(val segmentText: String)
|
||||
|
||||
@Immutable class ImageSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class LinkSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class EmojiSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class InvoiceSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class WithdrawSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class CashuSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class EmailSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class PhoneSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable class BechSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) :
|
||||
Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class HashIndexUserSegment(segment: String, hex: String, extras: String?) :
|
||||
HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashIndexEventSegment(segment: String, hex: String, extras: String?) :
|
||||
HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) :
|
||||
Segment(segment)
|
||||
|
||||
@Immutable class RegularTextSegment(segment: String) : Segment(segment)
|
||||
|
||||
fun startsWithNIP19Scheme(word: String): Boolean {
|
||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||
|
||||
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -42,6 +43,24 @@ data class CashuToken(
|
||||
val proofs: JsonNode,
|
||||
)
|
||||
|
||||
object CachedCashuProcessor {
|
||||
val cashuCache = LruCache<String, GenericLoadable<CashuToken>>(20)
|
||||
|
||||
fun cached(token: String): GenericLoadable<CashuToken> {
|
||||
return cashuCache[token] ?: GenericLoadable.Loading()
|
||||
}
|
||||
|
||||
fun parse(token: String): GenericLoadable<CashuToken> {
|
||||
if (cashuCache[token] !is GenericLoadable.Loaded) {
|
||||
val newCachuData = CashuProcessor().parse(token)
|
||||
|
||||
cashuCache.put(token, newCachuData)
|
||||
}
|
||||
|
||||
return cashuCache[token]
|
||||
}
|
||||
}
|
||||
|
||||
class CashuProcessor {
|
||||
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.HandlerThread
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -92,7 +93,33 @@ class LocationUtil(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
class ReverseGeoLocationUtil {
|
||||
object CachedGeoLocations {
|
||||
val locationNames = LruCache<String, String>(20)
|
||||
|
||||
fun cached(geoHashStr: String): String? {
|
||||
return locationNames[geoHashStr]
|
||||
}
|
||||
|
||||
suspend fun geoLocate(
|
||||
geoHashStr: String,
|
||||
location: Location,
|
||||
context: Context,
|
||||
): String? {
|
||||
locationNames[geoHashStr]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
|
||||
|
||||
if (name != null) {
|
||||
locationNames.put(geoHashStr, name)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
private class ReverseGeoLocationUtil {
|
||||
suspend fun execute(
|
||||
location: Location,
|
||||
context: Context,
|
||||
|
||||
@@ -22,7 +22,8 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
@@ -31,29 +32,64 @@ import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
object Nip11CachedRetriever {
|
||||
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
|
||||
open class RetrieveResult(val time: Long)
|
||||
|
||||
class RetrieveResultError(val error: Nip11Retriever.ErrorCode, val msg: String? = null) : RetrieveResult(TimeUtils.now())
|
||||
|
||||
class RetrieveResultSuccess(val data: Nip11RelayInformation) : RetrieveResult(TimeUtils.now())
|
||||
|
||||
val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
|
||||
val retriever = Nip11Retriever()
|
||||
|
||||
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
|
||||
val result = relayInformationDocumentCache.get(retriever.cleanUrl(dirtyUrl)) ?: return null
|
||||
if (result is RetrieveResultSuccess) return result.data
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
val url = retriever.cleanUrl(dirtyUrl)
|
||||
val doc = relayInformationDocumentCache.get(url)
|
||||
|
||||
if (doc != null) {
|
||||
onInfo(doc)
|
||||
if (doc is RetrieveResultSuccess) {
|
||||
onInfo(doc.data)
|
||||
} else if (doc is RetrieveResultError) {
|
||||
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
|
||||
onError(dirtyUrl, doc.error, null)
|
||||
} else {
|
||||
Nip11Retriever()
|
||||
.loadRelayInfo(
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
onInfo(it)
|
||||
},
|
||||
onError = { dirtyUrl, code, errorMsg ->
|
||||
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
|
||||
onError(url, code, errorMsg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Nip11Retriever()
|
||||
.loadRelayInfo(
|
||||
url,
|
||||
dirtyUrl,
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, it)
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
onInfo(it)
|
||||
},
|
||||
onError,
|
||||
onError = { dirtyUrl, code, errorMsg ->
|
||||
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
|
||||
onError(url, code, errorMsg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -78,9 +114,10 @@ class Nip11Retriever {
|
||||
suspend fun loadRelayInfo(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
try {
|
||||
val request: Request =
|
||||
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
|
||||
@@ -98,7 +135,7 @@ class Nip11Retriever {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(body))
|
||||
onInfo(Nip11RelayInformation.fromJson(body))
|
||||
} else {
|
||||
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexValidator
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
@@ -66,83 +69,118 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
null
|
||||
}
|
||||
|
||||
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
|
||||
when (val parsed = Nip19Bech32.uriToRoute(mySearchString)?.entity) {
|
||||
is Nip19Bech32.NSec -> KeyPair(privKey = parsed.hex.bechToBytes()).pubKey.toHexKey()
|
||||
is Nip19Bech32.NPub -> parsed.hex
|
||||
is Nip19Bech32.NProfile -> parsed.hex
|
||||
is Nip19Bech32.Note -> parsed.hex
|
||||
is Nip19Bech32.NEvent -> parsed.hex
|
||||
is Nip19Bech32.NEmbed -> parsed.event.id
|
||||
is Nip19Bech32.NRelay -> null
|
||||
is Nip19Bech32.NAddress -> parsed.atag
|
||||
else -> isAStraightHex
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return listOfNotNull(
|
||||
val directReferenceFilters =
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch),
|
||||
if (it.contains(":")) {
|
||||
// naddr
|
||||
listOfNotNull(
|
||||
ATag.parse(it, null)?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, aTag.kind),
|
||||
authors = listOfNotNull(aTag.pubKeyHex),
|
||||
// just to be sure
|
||||
limit = 5,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// event ids
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
hexToWatch?.let {
|
||||
// authors
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = listOfNotNull(hexToWatch),
|
||||
// just to be sure
|
||||
limit = 5,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return directReferenceFilters +
|
||||
listOfNotNull(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = listOfNotNull(hexToWatch),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
)
|
||||
},
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
BadgeDefinitionEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
BookmarkListEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ChannelCreateEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
ChannelMetadataEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
CommunityDefinitionEvent.KIND,
|
||||
EmojiPackEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
NNSEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
BadgeDefinitionEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
BookmarkListEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ChannelCreateEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
ChannelMetadataEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
CommunityDefinitionEvent.KIND,
|
||||
EmojiPackEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
NNSEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val searchChannel = requestNewChannel()
|
||||
|
||||
@@ -29,13 +29,16 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
@@ -120,27 +123,53 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
return groupByEOSEPresence(eventsToWatch).map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 1000,
|
||||
),
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
OtsEvent.KIND,
|
||||
TextNoteModificationEvent.KIND,
|
||||
GitReplyEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 1000,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
private fun createQuotesFilter(): List<TypedFilter>? {
|
||||
if (eventsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return groupByEOSEPresence(eventsToWatch).map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
tags = mapOf("q" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 1000,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
@@ -205,9 +234,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createReactionsToWatchInAddressFilter()
|
||||
val quotes = createQuotesFilter()
|
||||
|
||||
singleEventChannel.typedFilters =
|
||||
listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
|
||||
listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: Note) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Stable
|
||||
data class InvoiceAmount(val invoice: String, val amount: String?)
|
||||
|
||||
object CachedLnInvoiceParser {
|
||||
val lnInvoicesCache = LruCache<String, InvoiceAmount>(20)
|
||||
|
||||
fun cached(lnurl: String): InvoiceAmount? {
|
||||
return lnInvoicesCache[lnurl]
|
||||
}
|
||||
|
||||
fun parse(lnbcWord: String): InvoiceAmount? {
|
||||
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
|
||||
if (myInvoice != null) {
|
||||
val myInvoiceAmount =
|
||||
try {
|
||||
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
|
||||
|
||||
lnInvoicesCache.put(lnbcWord, lnInvoice)
|
||||
|
||||
return lnInvoice
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
event.pubKey != acc.userProfile().pubkeyHex
|
||||
) { // from the user
|
||||
|
||||
val chatNote = LocalCache.notes[event.id] ?: return
|
||||
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
@@ -145,7 +145,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
event: PrivateDmEvent,
|
||||
acc: Account,
|
||||
) {
|
||||
val note = LocalCache.notes[event.id] ?: return
|
||||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
@@ -184,7 +184,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
event: LnZapEvent,
|
||||
acc: Account,
|
||||
) {
|
||||
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.ots
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.ots.BitcoinExplorer
|
||||
import com.vitorpamplona.quartz.ots.BlockHeader
|
||||
import com.vitorpamplona.quartz.ots.exceptions.UrlException
|
||||
import okhttp3.Request
|
||||
|
||||
class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
/**
|
||||
* Retrieve the block information from the block hash.
|
||||
*
|
||||
* @param hash Hash of the block.
|
||||
* @return the blockheader of the hash
|
||||
* @throws Exception desc
|
||||
*/
|
||||
override fun block(hash: String): BlockHeader {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$BLOCKSTREAM_API_URL/block/$hash"
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.header("Accept", "application/json")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val jsonObject = jacksonObjectMapper().readTree(it.body.string())
|
||||
|
||||
val blockHeader = BlockHeader()
|
||||
blockHeader.merkleroot = jsonObject["merkle_root"].asText()
|
||||
blockHeader.setTime(jsonObject["timestamp"].asInt().toString())
|
||||
blockHeader.blockHash = hash
|
||||
Log.d("OkHttpBlockstreamExplorer", "$BLOCKSTREAM_API_URL/block/$hash")
|
||||
return blockHeader
|
||||
} else {
|
||||
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block hash from the block height.
|
||||
*
|
||||
* @param height Height of the block.
|
||||
* @return the hash of the block at height height
|
||||
* @throws Exception desc
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun blockHash(height: Int): String {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
val url = "$BLOCKSTREAM_API_URL/block-height/$height"
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val blockHash = it.body.string()
|
||||
|
||||
Log.d("OkHttpBlockstreamExplorer", "$url $blockHash")
|
||||
return blockHash
|
||||
} else {
|
||||
throw UrlException(it.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BLOCKSTREAM_API_URL = "https://blockstream.info/api"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.ots
|
||||
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.ots.ICalendar
|
||||
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
|
||||
import com.vitorpamplona.quartz.ots.Timestamp
|
||||
import com.vitorpamplona.quartz.ots.exceptions.CommitmentNotFoundException
|
||||
import com.vitorpamplona.quartz.ots.exceptions.DeserializationException
|
||||
import com.vitorpamplona.quartz.ots.exceptions.ExceededSizeException
|
||||
import com.vitorpamplona.quartz.ots.exceptions.UrlException
|
||||
import com.vitorpamplona.quartz.ots.http.Request
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* Class representing remote calendar server interface.
|
||||
*/
|
||||
class OkHttpCalendar(val url: String) : ICalendar {
|
||||
/**
|
||||
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
|
||||
*
|
||||
* @param digest The digest hash to send.
|
||||
* @return the Timestamp received from the calendar.
|
||||
* @throws ExceededSizeException if response is too big.
|
||||
* @throws UrlException if url is not reachable.
|
||||
* @throws DeserializationException if the data is corrupt
|
||||
*/
|
||||
@Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class)
|
||||
override fun submit(digest: ByteArray): Timestamp {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$url/digest"
|
||||
|
||||
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
|
||||
val requestBody = digest.toRequestBody(mediaType)
|
||||
|
||||
val request =
|
||||
okhttp3.Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.header("Accept", "application/vnd.opentimestamps.v1")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val ctx = StreamDeserializationContext(it.body.bytes())
|
||||
return Timestamp.deserialize(ctx, digest)
|
||||
} else {
|
||||
throw UrlException("Failed to open $url")
|
||||
}
|
||||
}
|
||||
} catch (e: ExceededSizeException) {
|
||||
throw e
|
||||
} catch (e: DeserializationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw UrlException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a timestamp for a given commitment.
|
||||
*
|
||||
* @param commitment The digest hash to send.
|
||||
* @return the Timestamp from the calendar server (with blockchain information if already written).
|
||||
* @throws ExceededSizeException if response is too big.
|
||||
* @throws UrlException if url is not reachable.
|
||||
* @throws CommitmentNotFoundException if commit is not found.
|
||||
* @throws DeserializationException if the data is corrupt
|
||||
*/
|
||||
@Throws(
|
||||
DeserializationException::class,
|
||||
ExceededSizeException::class,
|
||||
CommitmentNotFoundException::class,
|
||||
UrlException::class,
|
||||
)
|
||||
override fun getTimestamp(commitment: ByteArray): Timestamp {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = url + "/timestamp/" + Hex.encode(commitment)
|
||||
|
||||
val request =
|
||||
okhttp3.Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.header("Accept", "application/vnd.opentimestamps.v1")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val ctx = StreamDeserializationContext(it.body.bytes())
|
||||
return Timestamp.deserialize(ctx, commitment)
|
||||
} else {
|
||||
throw CommitmentNotFoundException("Calendar response a status code != 200: " + it.code)
|
||||
}
|
||||
}
|
||||
} catch (e: DeserializationException) {
|
||||
throw e
|
||||
} catch (e: ExceededSizeException) {
|
||||
throw e
|
||||
} catch (e: CommitmentNotFoundException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw UrlException(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.ots
|
||||
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
|
||||
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
|
||||
import com.vitorpamplona.quartz.ots.Timestamp
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.BlockingQueue
|
||||
|
||||
/**
|
||||
* For making async calls to a calendar server
|
||||
*/
|
||||
class OkHttpCalendarAsyncSubmit(private val url: String, private val digest: ByteArray) : ICalendarAsyncSubmit {
|
||||
private var queue: BlockingQueue<Optional<Timestamp>>? = null
|
||||
|
||||
fun setQueue(queue: BlockingQueue<Optional<Timestamp>>?) {
|
||||
this.queue = queue
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun call(): Optional<Timestamp> {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$url/digest"
|
||||
|
||||
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
|
||||
val requestBody = digest.toRequestBody(mediaType)
|
||||
|
||||
val request =
|
||||
okhttp3.Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.header("Accept", "application/vnd.opentimestamps.v1")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val ctx = StreamDeserializationContext(it.body.bytes())
|
||||
val timestamp = Timestamp.deserialize(ctx, digest)
|
||||
val of = Optional.of(timestamp)
|
||||
queue!!.add(of)
|
||||
return of
|
||||
} else {
|
||||
queue!!.add(Optional.empty())
|
||||
return Optional.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,21 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
package com.vitorpamplona.amethyst.service.ots
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.ots.CalendarBuilder
|
||||
import com.vitorpamplona.quartz.ots.ICalendar
|
||||
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
|
||||
|
||||
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
|
||||
class OkHttpCalendarBuilder : CalendarBuilder {
|
||||
override fun newSyncCalendar(url: String): ICalendar {
|
||||
return OkHttpCalendar(url)
|
||||
}
|
||||
|
||||
override fun newAsyncCalendar(
|
||||
url: String,
|
||||
digest: ByteArray,
|
||||
): ICalendarAsyncSubmit {
|
||||
return OkHttpCalendarAsyncSubmit(url, digest)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ object Client : RelayPool.Listener {
|
||||
checkNotInMainThread()
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId)
|
||||
RelayPool.connectAndSendFiltersIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(
|
||||
@@ -292,7 +292,7 @@ object Client : RelayPool.Listener {
|
||||
open fun onRelayStateChange(
|
||||
type: Relay.StateType,
|
||||
relay: Relay,
|
||||
channel: String?,
|
||||
subscriptionId: String?,
|
||||
) = Unit
|
||||
|
||||
/** When an relay saves or rejects a new event. */
|
||||
|
||||
@@ -37,26 +37,26 @@ class JsonFilter(
|
||||
val filter =
|
||||
factory.objectNode().apply {
|
||||
ids?.run {
|
||||
put(
|
||||
replace(
|
||||
"ids",
|
||||
factory.arrayNode(ids.size).apply { ids.forEach { add(it) } },
|
||||
)
|
||||
}
|
||||
authors?.run {
|
||||
put(
|
||||
replace(
|
||||
"authors",
|
||||
factory.arrayNode(authors.size).apply { authors.forEach { add(it) } },
|
||||
)
|
||||
}
|
||||
kinds?.run {
|
||||
put(
|
||||
replace(
|
||||
"kinds",
|
||||
factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } },
|
||||
)
|
||||
}
|
||||
tags?.run {
|
||||
entries.forEach { kv ->
|
||||
put(
|
||||
replace(
|
||||
"#${kv.key}",
|
||||
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
|
||||
)
|
||||
|
||||
@@ -409,7 +409,7 @@ class Relay(
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
|
||||
fun connectAndSendFiltersIfDisconnected() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (socket == null) {
|
||||
|
||||
@@ -81,8 +81,8 @@ object RelayPool : Relay.Listener {
|
||||
relays.forEach { it.sendFilter(subscriptionId) }
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
|
||||
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
|
||||
fun connectAndSendFiltersIfDisconnected() {
|
||||
relays.forEach { it.connectAndSendFiltersIfDisconnected() }
|
||||
}
|
||||
|
||||
fun sendToSelectedRelays(
|
||||
|
||||
@@ -47,7 +47,7 @@ data class Subscription(
|
||||
return factory.objectNode().apply {
|
||||
put("id", id)
|
||||
typedFilters?.also { filters ->
|
||||
put(
|
||||
replace(
|
||||
"typedFilters",
|
||||
factory.arrayNode(filters.size).apply {
|
||||
filters.forEach { filter -> add(filter.toJsonObject()) }
|
||||
|
||||
@@ -36,8 +36,8 @@ class TypedFilter(
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
return factory.objectNode().apply {
|
||||
put("types", typesToJson(types))
|
||||
put("filter", filterToJson(filter))
|
||||
replace("types", typesToJson(types))
|
||||
replace("filter", filterToJson(filter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,26 +50,26 @@ class TypedFilter(
|
||||
val factory = Event.mapper.nodeFactory
|
||||
return factory.objectNode().apply {
|
||||
filter.ids?.run {
|
||||
put(
|
||||
replace(
|
||||
"ids",
|
||||
factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } },
|
||||
)
|
||||
}
|
||||
filter.authors?.run {
|
||||
put(
|
||||
replace(
|
||||
"authors",
|
||||
factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } },
|
||||
)
|
||||
}
|
||||
filter.kinds?.run {
|
||||
put(
|
||||
replace(
|
||||
"kinds",
|
||||
factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } },
|
||||
)
|
||||
}
|
||||
filter.tags?.run {
|
||||
entries.forEach { kv ->
|
||||
put(
|
||||
replace(
|
||||
"#${kv.key}",
|
||||
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
|
||||
)
|
||||
|
||||
@@ -45,6 +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.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
@@ -52,12 +53,12 @@ import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.debugState
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47WalletConnectParser
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
@@ -311,11 +312,12 @@ fun uriToRoute(uri: String?): String? {
|
||||
if (uri?.startsWith("nostr:Hashtag?id=") == true) {
|
||||
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id="))
|
||||
} else {
|
||||
val nip19 = Nip19.uriToRoute(uri)
|
||||
when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> {
|
||||
val nip19 = Nip19Bech32.uriToRoute(uri)?.entity
|
||||
when (nip19) {
|
||||
is Nip19Bech32.NPub -> "User/${nip19.hex}"
|
||||
is Nip19Bech32.NProfile -> "User/${nip19.hex}"
|
||||
is Nip19Bech32.Note -> "Note/${nip19.hex}"
|
||||
is Nip19Bech32.NEvent -> {
|
||||
if (nip19.kind == PrivateDmEvent.KIND) {
|
||||
nip19.author?.let { "RoomByAuthor/$it" }
|
||||
} else if (
|
||||
@@ -328,20 +330,27 @@ fun uriToRoute(uri: String?): String? {
|
||||
"Event/${nip19.hex}"
|
||||
}
|
||||
}
|
||||
Nip19.Type.ADDRESS ->
|
||||
is Nip19Bech32.NAddress -> {
|
||||
if (nip19.kind == CommunityDefinitionEvent.KIND) {
|
||||
"Community/${nip19.hex}"
|
||||
"Community/${nip19.atag}"
|
||||
} else if (nip19.kind == LiveActivitiesEvent.KIND) {
|
||||
"Channel/${nip19.hex}"
|
||||
"Channel/${nip19.atag}"
|
||||
} else {
|
||||
"Event/${nip19.hex}"
|
||||
"Event/${nip19.atag}"
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NEmbed -> {
|
||||
if (LocalCache.getNoteIfExists(nip19.event.id) == null) {
|
||||
LocalCache.verifyAndConsume(nip19.event, null)
|
||||
}
|
||||
"Event/${nip19.event.id}"
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
?: try {
|
||||
uri?.let {
|
||||
Nip47WalletConnectParser.parse(it)
|
||||
Nip47WalletConnect.parse(it)
|
||||
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
|
||||
Route.Home.base + "?nip47=" + encodedUri
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
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
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditPostView(
|
||||
onClose: () -> Unit,
|
||||
edit: Note,
|
||||
versionLookingAt: Note?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val postViewModel: EditPostViewModel = viewModel()
|
||||
postViewModel.prepare(edit, versionLookingAt, accountViewModel)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRelaysDialog by remember { mutableStateOf(false) }
|
||||
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(edit, versionLookingAt, accountViewModel)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
NostrSearchEventOrUserDataSource.start()
|
||||
|
||||
onDispose {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
preSelectedList = relayList,
|
||||
onClose = { showRelaysDialog = false },
|
||||
onPost = { relayList = it },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = { showRelaysDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
contentDescription = stringResource(id = R.string.relay_list_selector),
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost(relayList = relayList)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost(),
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Row {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
postViewModel.cancel()
|
||||
scope.launch {
|
||||
delay(100)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
start = Size10dp,
|
||||
top = pad.calculateTopPadding(),
|
||||
end = Size10dp,
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.imePadding()
|
||||
.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
postViewModel.editedFromNote?.let {
|
||||
Row(Modifier.heightIn(max = 200.dp)) {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
MessageField(postViewModel)
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
if (RichTextParser.isValidURL(myUrlPreview)) {
|
||||
if (RichTextParser.isImageUrl(myUrlPreview)) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.subtleBorder,
|
||||
QuoteBorder,
|
||||
),
|
||||
)
|
||||
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
|
||||
VideoView(
|
||||
myUrlPreview,
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
}
|
||||
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
|
||||
val bgColor = MaterialTheme.colorScheme.background
|
||||
val backgroundColor = remember { mutableStateOf(bgColor) }
|
||||
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val url = postViewModel.contentToAddUrl
|
||||
if (url != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
accountViewModel.account.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
|
||||
if (!server.isNip95) {
|
||||
accountViewModel.account.changeDefaultFileServer(server.server)
|
||||
}
|
||||
},
|
||||
onCancel = { postViewModel.contentToAddUrl = null },
|
||||
onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val user = postViewModel.account?.userProfile()
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
|
||||
if (lud16 != null && postViewModel.wantsInvoice) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
user.pubkeyHex,
|
||||
accountViewModel.account,
|
||||
stringResource(id = R.string.lightning_invoice),
|
||||
stringResource(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
postViewModel.message =
|
||||
TextFieldValue(postViewModel.message.text + "\n\n" + it)
|
||||
postViewModel.wantsInvoice = false
|
||||
},
|
||||
onClose = { postViewModel.wantsInvoice = false },
|
||||
onError = { title, message -> accountViewModel.toast(title, message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.message_to_author),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
MyTextField(
|
||||
value = postViewModel.subject,
|
||||
onValueChange = { postViewModel.updateSubject(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.message_to_author_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
ShowUserSuggestionListForEdit(
|
||||
postViewModel,
|
||||
accountViewModel,
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp),
|
||||
)
|
||||
|
||||
BottomRowActions(postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowUserSuggestionListForEdit(
|
||||
editPostViewModel: EditPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
|
||||
) {
|
||||
val userSuggestions = editPostViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
top = 10.dp,
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex },
|
||||
) { _, item ->
|
||||
UserLine(item, accountViewModel) { editPostViewModel.autocompleteWithUser(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomRowActions(postViewModel: EditPostViewModel) {
|
||||
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.canAddInvoice) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageField(postViewModel: EditPostViewModel) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = { postViewModel.updateMessage(it) },
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.what_s_on_your_mind),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddLnInvoiceButton(
|
||||
isLnInvoiceActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onClick() },
|
||||
) {
|
||||
if (!isLnInvoiceActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
contentDescription = stringResource(id = R.string.add_bitcoin_invoice),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
open class EditPostViewModel() : ViewModel() {
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
var account: Account? = null
|
||||
|
||||
var editedFromNote: Note? = null
|
||||
|
||||
var subject by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
|
||||
var nip95attachments by
|
||||
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError =
|
||||
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(null)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
open fun prepare(
|
||||
edit: Note,
|
||||
versionLookingAt: Note?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
this.editedFromNote = edit
|
||||
}
|
||||
|
||||
open fun load(
|
||||
edit: Note,
|
||||
versionLookingAt: Note?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
contentToAddUrl = null
|
||||
|
||||
message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
editedFromNote = edit
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<Relay>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
|
||||
}
|
||||
|
||||
suspend fun innerSendPost(relayList: List<Relay>? = null) {
|
||||
if (accountViewModel == null) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
nip95attachments.forEach {
|
||||
account?.sendNip95(it.first, it.second, relayList)
|
||||
}
|
||||
|
||||
val notify =
|
||||
if (editedFromNote?.author?.pubkeyHex == account?.userProfile()?.pubkeyHex) {
|
||||
null
|
||||
} else {
|
||||
// notifies if it is not the logged in user
|
||||
editedFromNote?.author?.pubkeyHex
|
||||
}
|
||||
|
||||
account?.sendEdit(
|
||||
message = message.text,
|
||||
originalNote = editedFromNote!!,
|
||||
notify = notify,
|
||||
summary = subject.text.ifBlank { null },
|
||||
relayList = relayList,
|
||||
)
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
open fun updateSubject(it: TextFieldValue) {
|
||||
subject = it
|
||||
}
|
||||
|
||||
fun upload(
|
||||
galleryUri: Uri,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
context: Context,
|
||||
) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val contentType = contentResolver.getType(galleryUri)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
MediaCompressor()
|
||||
.compress(
|
||||
galleryUri,
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.isNip95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
contentResolver = contentResolver,
|
||||
onProgress = {},
|
||||
)
|
||||
|
||||
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}",
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
subject = TextFieldValue("")
|
||||
|
||||
editedFromNote = null
|
||||
|
||||
contentToAddUrl = null
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
|
||||
wantsInvoice = false
|
||||
|
||||
userSuggestions = emptyList()
|
||||
userSuggestionAnchor = null
|
||||
userSuggestionsMainMessage = null
|
||||
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
}
|
||||
|
||||
open fun findUrlInMessage(): String? {
|
||||
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
paragraph.split(' ').firstOrNull { word: String ->
|
||||
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun updateMessage(it: TextFieldValue) {
|
||||
message = it
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
if (it.selection.collapsed) {
|
||||
val lastWord =
|
||||
it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
userSuggestionAnchor = it.selection
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions =
|
||||
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
|
||||
.reversed()
|
||||
}
|
||||
} else {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun autocompleteWithUser(item: User) {
|
||||
userSuggestionAnchor?.let {
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord =
|
||||
message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
|
||||
message =
|
||||
TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
|
||||
)
|
||||
}
|
||||
|
||||
userSuggestionAnchor = null
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return message.text.isNotBlank() &&
|
||||
!isUploadingImage &&
|
||||
!wantsInvoice &&
|
||||
contentToAddUrl == null
|
||||
}
|
||||
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType =
|
||||
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
|
||||
val originalHash =
|
||||
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
|
||||
val dim =
|
||||
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
val magnet =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
nip94attachments = nip94attachments + event
|
||||
|
||||
message = message.insertUrlAtCursor(imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Media is too big for NIP-95")
|
||||
isUploadingImage = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
mimeType,
|
||||
null,
|
||||
onReady = {
|
||||
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectImage(uri: Uri) {
|
||||
contentToAddUrl = uri
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.Bech32
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -68,17 +68,23 @@ class NewMessageTagger(
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
addUserToMentions(dao.getOrCreateUser(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
addNoteToReplyTos(note)
|
||||
when (val entity = results?.key?.entity) {
|
||||
is Nip19Bech32.NPub -> addUserToMentions(dao.getOrCreateUser(entity.hex))
|
||||
is Nip19Bech32.NProfile -> addUserToMentions(dao.getOrCreateUser(entity.hex))
|
||||
|
||||
is Nip19Bech32.Note -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex))
|
||||
is Nip19Bech32.NEvent -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex))
|
||||
is Nip19Bech32.NEmbed -> addNoteToReplyTos(dao.getOrCreateNote(entity.event.id))
|
||||
|
||||
is Nip19Bech32.NAddress -> {
|
||||
val note = dao.checkGetOrCreateAddressableNote(entity.atag)
|
||||
if (note != null) {
|
||||
addNoteToReplyTos(note)
|
||||
}
|
||||
}
|
||||
|
||||
is Nip19Bech32.NSec -> {}
|
||||
is Nip19Bech32.NRelay -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,27 +98,45 @@ class NewMessageTagger(
|
||||
.split(' ')
|
||||
.map { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
val user = dao.getOrCreateUser(results.key.hex)
|
||||
when (val entity = results?.key?.entity) {
|
||||
is Nip19Bech32.NPub -> {
|
||||
getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord)
|
||||
}
|
||||
is Nip19Bech32.NProfile -> {
|
||||
getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord)
|
||||
}
|
||||
|
||||
getNostrAddress(user.pubkeyNpub(), results.restOfWord)
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = dao.getOrCreateNote(results.key.hex)
|
||||
is Nip19Bech32.Note -> {
|
||||
getNostrAddress(dao.getOrCreateNote(entity.hex).toNEvent(), results.restOfWord)
|
||||
}
|
||||
is Nip19Bech32.NEvent -> {
|
||||
getNostrAddress(dao.getOrCreateNote(entity.hex).toNEvent(), results.restOfWord)
|
||||
}
|
||||
|
||||
getNostrAddress(note.toNEvent(), results.restOfWord)
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
val note = dao.getOrCreateNote(results.key.hex)
|
||||
is Nip19Bech32.NAddress -> {
|
||||
val note = dao.checkGetOrCreateAddressableNote(entity.atag)
|
||||
if (note != null) {
|
||||
getNostrAddress(note.idNote(), results.restOfWord)
|
||||
} else {
|
||||
word
|
||||
}
|
||||
}
|
||||
|
||||
getNostrAddress(note.toNEvent(), results.restOfWord)
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
getNostrAddress(note.idNote(), results.restOfWord)
|
||||
} else {
|
||||
is Nip19Bech32.NEmbed -> {
|
||||
word
|
||||
}
|
||||
|
||||
is Nip19Bech32.NSec -> {
|
||||
word
|
||||
}
|
||||
|
||||
is Nip19Bech32.NRelay -> {
|
||||
word
|
||||
}
|
||||
|
||||
else -> {
|
||||
word
|
||||
}
|
||||
} else {
|
||||
word
|
||||
}
|
||||
}
|
||||
.joinToString(" ")
|
||||
@@ -135,7 +159,7 @@ class NewMessageTagger(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
|
||||
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
var key = mightBeAKey
|
||||
@@ -155,7 +179,7 @@ class NewMessageTagger(
|
||||
val restOfWord = key.substring(63)
|
||||
// Converts to npub
|
||||
val pubkey =
|
||||
Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
|
||||
Nip19Bech32.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
@@ -166,7 +190,7 @@ class NewMessageTagger(
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
|
||||
val pubkey = Nip19Bech32.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
@@ -177,19 +201,19 @@ class NewMessageTagger(
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
val noteId = Nip19.uriToRoute(keyB32) ?: return null
|
||||
val noteId = Nip19Bech32.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteId, restOfWord)
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19.uriToRoute(key) ?: return null
|
||||
val pubkeyRelay = Nip19Bech32.uriToRoute(key) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
|
||||
} else if (key.startsWith("nevent1", true)) {
|
||||
val noteRelayId = Nip19.uriToRoute(key) ?: return null
|
||||
val noteRelayId = Nip19Bech32.uriToRoute(key) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
|
||||
} else if (key.startsWith("naddr1", true)) {
|
||||
val address = Nip19.uriToRoute(key) ?: return null
|
||||
val address = Nip19Bech32.uriToRoute(key) ?: return null
|
||||
|
||||
return DirtyKeyInfo(
|
||||
address,
|
||||
|
||||
@@ -124,31 +124,25 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.CancelIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.CloseIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.PollIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
|
||||
@@ -191,6 +185,8 @@ fun NewPostView(
|
||||
onClose: () -> Unit,
|
||||
baseReplyTo: Note? = null,
|
||||
quote: Note? = null,
|
||||
fork: Note? = null,
|
||||
version: Note? = null,
|
||||
enableMessageInterface: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
@@ -206,7 +202,7 @@ fun NewPostView(
|
||||
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(accountViewModel, baseReplyTo, quote)
|
||||
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
@@ -385,6 +381,52 @@ fun NewPostView(
|
||||
}
|
||||
}
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
if (RichTextParser.isValidURL(myUrlPreview)) {
|
||||
if (RichTextParser.isImageUrl(myUrlPreview)) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.subtleBorder,
|
||||
QuoteBorder,
|
||||
),
|
||||
)
|
||||
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
|
||||
VideoView(
|
||||
myUrlPreview,
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
}
|
||||
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
|
||||
val bgColor = MaterialTheme.colorScheme.background
|
||||
val backgroundColor = remember { mutableStateOf(bgColor) }
|
||||
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsToMarkAsSensitive) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -434,34 +476,32 @@ fun NewPostView(
|
||||
}
|
||||
}
|
||||
|
||||
val user = postViewModel.account?.userProfile()
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
|
||||
if (lud16 != null && postViewModel.wantsInvoice) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
user.pubkeyHex,
|
||||
accountViewModel.account,
|
||||
stringResource(id = R.string.lightning_invoice),
|
||||
stringResource(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
postViewModel.message =
|
||||
TextFieldValue(postViewModel.message.text + "\n\n" + it)
|
||||
postViewModel.wantsInvoice = false
|
||||
},
|
||||
onClose = { postViewModel.wantsInvoice = false },
|
||||
onError = { title, message -> accountViewModel.toast(title, message) },
|
||||
)
|
||||
if (postViewModel.wantsInvoice) {
|
||||
postViewModel.lnAddress()?.let { lud16 ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
accountViewModel.account.userProfile().pubkeyHex,
|
||||
accountViewModel.account,
|
||||
stringResource(id = R.string.lightning_invoice),
|
||||
stringResource(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
postViewModel.insertAtCursor(it)
|
||||
postViewModel.wantsInvoice = false
|
||||
},
|
||||
onClose = { postViewModel.wantsInvoice = false },
|
||||
onError = { title, message -> accountViewModel.toast(title, message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lud16 != null && postViewModel.wantsZapraiser) {
|
||||
if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
@@ -472,54 +512,6 @@ fun NewPostView(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl =
|
||||
removeQueryParamsForExtensionComparison(myUrlPreview)
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.subtleBorder,
|
||||
QuoteBorder,
|
||||
),
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
VideoView(
|
||||
myUrlPreview,
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
}
|
||||
} else if (startsWithNIP19Scheme(myUrlPreview)) {
|
||||
val bgColor = MaterialTheme.colorScheme.background
|
||||
val backgroundColor = remember { mutableStateOf(bgColor) }
|
||||
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,7 +568,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddInvoice) {
|
||||
if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
@@ -1178,23 +1170,12 @@ fun FowardZapTo(
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun LocationAsHash(postViewModel: NewPostViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val locationPermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
)
|
||||
|
||||
if (locationPermissionState.status.isGranted) {
|
||||
var locationDescriptionFlow by remember(postViewModel) { mutableStateOf<Flow<String>?>(null) }
|
||||
|
||||
DisposableEffect(key1 = Unit) {
|
||||
postViewModel.startLocation(context = context)
|
||||
locationDescriptionFlow = postViewModel.location
|
||||
|
||||
onDispose { postViewModel.stopLocation() }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
@@ -1225,7 +1206,7 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
|
||||
locationDescriptionFlow?.let { geoLocation -> DisplayLocationObserver(geoLocation) }
|
||||
DisplayLocationObserver(postViewModel)
|
||||
}
|
||||
|
||||
Divider()
|
||||
@@ -1242,41 +1223,39 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayLocationObserver(geoLocation: Flow<String>) {
|
||||
val location by geoLocation.collectAsStateWithLifecycle(null)
|
||||
fun DisplayLocationObserver(postViewModel: NewPostViewModel) {
|
||||
val context = LocalContext.current
|
||||
var locationDescriptionFlow by remember(postViewModel) { mutableStateOf<Flow<String>?>(null) }
|
||||
|
||||
location?.let { DisplayLocationInTitle(geohash = it) }
|
||||
DisposableEffect(key1 = context) {
|
||||
postViewModel.startLocation(context = context)
|
||||
locationDescriptionFlow = postViewModel.location
|
||||
|
||||
onDispose { postViewModel.stopLocation() }
|
||||
}
|
||||
|
||||
locationDescriptionFlow?.let {
|
||||
val location by it.collectAsStateWithLifecycle(null)
|
||||
|
||||
location?.let { DisplayLocationInTitle(geohash = it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayLocationInTitle(geohash: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var cityName by remember(geohash) { mutableStateOf<String>(geohash) }
|
||||
|
||||
LaunchedEffect(key1 = geohash) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newCityName =
|
||||
ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank {
|
||||
null
|
||||
}
|
||||
|
||||
if (newCityName != null && newCityName != cityName) {
|
||||
cityName = newCityName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (geohash != "s0000") {
|
||||
LoadCityName(
|
||||
geohashStr = geohash,
|
||||
onLoading = {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation()
|
||||
},
|
||||
) { cityName ->
|
||||
Text(
|
||||
text = cityName,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = Size5dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -43,11 +45,9 @@ import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.LocationUtil
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
@@ -55,9 +55,11 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
@@ -85,10 +87,10 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var requiresNIP24: Boolean = false
|
||||
|
||||
var originalNote: Note? = null
|
||||
var forkedFromNote: Note? = null
|
||||
|
||||
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
|
||||
@@ -162,10 +164,24 @@ open class NewPostViewModel() : ViewModel() {
|
||||
// NIP24 Wrapped DMs / Group messages
|
||||
var nip24 by mutableStateOf(false)
|
||||
|
||||
fun lnAddress(): String? {
|
||||
return account?.userProfile()?.info?.lnAddress()
|
||||
}
|
||||
|
||||
fun hasLnAddress(): Boolean {
|
||||
return account?.userProfile()?.info?.lnAddress() != null
|
||||
}
|
||||
|
||||
fun user(): User? {
|
||||
return account?.userProfile()
|
||||
}
|
||||
|
||||
open fun load(
|
||||
accountViewModel: AccountViewModel,
|
||||
replyingTo: Note?,
|
||||
quote: Note?,
|
||||
fork: Note?,
|
||||
version: Note?,
|
||||
) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
@@ -197,11 +213,6 @@ open class NewPostViewModel() : ViewModel() {
|
||||
pTags = null
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
@@ -214,6 +225,75 @@ open class NewPostViewModel() : ViewModel() {
|
||||
zapRaiserAmount = null
|
||||
forwardZapTo = Split()
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
it.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
|
||||
forwardZapTo.addItem(quotedUser)
|
||||
}
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
|
||||
forwardZapTo.addItem(accountViewModel.userProfile())
|
||||
}
|
||||
|
||||
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
|
||||
forwardZapTo.updatePercentage(pos, 0.9f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fork?.let {
|
||||
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
it.event?.isSensitive()?.let {
|
||||
if (it) wantsToMarkAsSensitive = true
|
||||
}
|
||||
|
||||
it.event?.zapraiserAmount()?.let {
|
||||
zapRaiserAmount = it
|
||||
}
|
||||
|
||||
it.event?.zapSplitSetup()?.let {
|
||||
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
|
||||
|
||||
it.forEach {
|
||||
if (!it.isLnAddress) {
|
||||
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only adds if it is not already set up.
|
||||
if (forwardZapTo.items.isEmpty()) {
|
||||
it.author?.let { forkedAuthor ->
|
||||
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
|
||||
|
||||
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
|
||||
forwardZapTo.updatePercentage(pos, 0.8f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it.author?.let {
|
||||
if (this.pTags == null) {
|
||||
this.pTags = listOf(it)
|
||||
} else if (this.pTags?.contains(it) != true) {
|
||||
this.pTags = listOf(it) + (this.pTags ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
forkedFromNote = it
|
||||
}
|
||||
|
||||
if (!forwardZapTo.items.isEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<Relay>? = null) {
|
||||
@@ -355,6 +435,45 @@ open class NewPostViewModel() : ViewModel() {
|
||||
nip94attachments = usedAttachments,
|
||||
)
|
||||
}
|
||||
} else if (originalNote?.event is GitIssueEvent) {
|
||||
val originalNoteEvent = originalNote?.event as GitIssueEvent
|
||||
// adds markers
|
||||
val rootId =
|
||||
originalNoteEvent.rootIssueOrPatch() // if it has a marker as root
|
||||
?: originalNote
|
||||
?.replyTo
|
||||
?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }
|
||||
?.idHex // if it has loaded events with zero replies in the reply list
|
||||
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
|
||||
?: originalNote?.idHex
|
||||
|
||||
val replyId = originalNote?.idHex
|
||||
|
||||
val replyToSet =
|
||||
if (forkedFromNote != null) {
|
||||
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
|
||||
} else {
|
||||
tagger.eTags
|
||||
}
|
||||
|
||||
val repositoryAddress = originalNoteEvent.repository()
|
||||
|
||||
account?.sendGitReply(
|
||||
message = tagger.message,
|
||||
replyTo = replyToSet,
|
||||
mentions = tagger.pTags,
|
||||
repository = repositoryAddress,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
replyingTo = replyId,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
)
|
||||
} else {
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(
|
||||
@@ -404,9 +523,16 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
val replyId = originalNote?.idHex
|
||||
|
||||
val replyToSet =
|
||||
if (forkedFromNote != null) {
|
||||
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
|
||||
} else {
|
||||
tagger.eTags
|
||||
}
|
||||
|
||||
account?.sendPost(
|
||||
message = tagger.message,
|
||||
replyTo = tagger.eTags,
|
||||
replyTo = replyToSet,
|
||||
mentions = tagger.pTags,
|
||||
tags = null,
|
||||
zapReceiver = zapReceiver,
|
||||
@@ -415,6 +541,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
replyingTo = replyId,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
@@ -504,7 +631,6 @@ open class NewPostViewModel() : ViewModel() {
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
pTags = null
|
||||
imetaTags.clear()
|
||||
|
||||
wantsDirectMessage = false
|
||||
|
||||
@@ -540,7 +666,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
open fun findUrlInMessage(): String? {
|
||||
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
paragraph.split(' ').firstOrNull { word: String ->
|
||||
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
|
||||
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -737,10 +863,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
isUploadingImage = false
|
||||
nip94attachments = nip94attachments + event
|
||||
|
||||
message =
|
||||
TextFieldValue(
|
||||
message.text + "\n" + imageUrl,
|
||||
)
|
||||
message = message.insertUrlAtCursor(imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
@@ -751,6 +874,10 @@ open class NewPostViewModel() : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
@@ -777,7 +904,9 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
note?.let { message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent()) }
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays
|
||||
@@ -193,8 +194,10 @@ fun NewRelayListView(
|
||||
onToggleSearch = { postViewModel.toggleSearch(it) },
|
||||
onDelete = { postViewModel.deleteRelay(it) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,9 +413,14 @@ fun ServerConfigClickableLine(
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
Column(Modifier.clickable(onClick = onClick)) {
|
||||
val iconUrlFromRelayInfoDoc =
|
||||
remember(item) {
|
||||
Nip11CachedRetriever.getFromCache(item.url)?.icon
|
||||
}
|
||||
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
item.briefInfo.favIcon,
|
||||
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
|
||||
@@ -62,7 +62,9 @@ class NewRelayListViewModel : ViewModel() {
|
||||
_relays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) },
|
||||
onInfo = {
|
||||
togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage -> },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
@@ -59,13 +58,14 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RelayInformationDialog(
|
||||
onClose: () -> Unit,
|
||||
relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
|
||||
relayInfo: RelayInformation,
|
||||
relayInfo: Nip11RelayInformation,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -104,7 +104,7 @@ fun RelayInformationDialog(
|
||||
Column {
|
||||
RenderRelayIcon(
|
||||
relayBriefInfo.displayUrl,
|
||||
relayBriefInfo.favIcon,
|
||||
relayInfo.icon ?: relayBriefInfo.favIcon,
|
||||
automaticallyShowProfilePicture,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
@@ -121,7 +121,12 @@ fun RelayInformationDialog(
|
||||
|
||||
Section(stringResource(R.string.owner))
|
||||
|
||||
relayInfo.pubkey?.let { DisplayOwnerInformation(it, accountViewModel, nav) }
|
||||
relayInfo.pubkey?.let {
|
||||
DisplayOwnerInformation(it, accountViewModel) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
|
||||
Section(stringResource(R.string.software))
|
||||
|
||||
@@ -170,12 +175,14 @@ fun RelayInformationDialog(
|
||||
|
||||
relayInfo.limitation?.let {
|
||||
Section(stringResource(R.string.limitations))
|
||||
val authRequired = it.auth_required ?: false
|
||||
val authRequiredText =
|
||||
if (authRequired) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
val paymentRequired = it.payment_required ?: false
|
||||
if (it.auth_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
val paymentRequiredText =
|
||||
if (paymentRequired) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
if (it.payment_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
val restrictedWritesText =
|
||||
if (it.restricted_writes ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
|
||||
|
||||
Column {
|
||||
SectionContent(
|
||||
@@ -184,7 +191,7 @@ fun RelayInformationDialog(
|
||||
SectionContent(
|
||||
"${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}",
|
||||
)
|
||||
SectionContent("${stringResource(R.string.filters)}: ${it.max_subscriptions ?: 0}")
|
||||
SectionContent("${stringResource(R.string.filters)}: ${it.max_filters ?: 0}")
|
||||
SectionContent(
|
||||
"${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}",
|
||||
)
|
||||
@@ -195,9 +202,13 @@ fun RelayInformationDialog(
|
||||
SectionContent(
|
||||
"${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}",
|
||||
)
|
||||
SectionContent(
|
||||
"${stringResource(R.string.max_limit)}: ${it.max_limit ?: 0}",
|
||||
)
|
||||
SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}")
|
||||
SectionContent("${stringResource(R.string.auth)}: $authRequiredText")
|
||||
SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText")
|
||||
SectionContent("${stringResource(R.string.restricted_writes)}: $restrictedWritesText")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +247,7 @@ fun RelayInformationDialog(
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun DisplaySupportedNips(relayInfo: RelayInformation) {
|
||||
private fun DisplaySupportedNips(relayInfo: Nip11RelayInformation) {
|
||||
FlowRow {
|
||||
relayInfo.supported_nips?.forEach { item ->
|
||||
val text = item.toString().padStart(2, '0')
|
||||
@@ -261,7 +272,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplaySoftwareInformation(relayInfo: RelayInformation) {
|
||||
private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) {
|
||||
val url = (relayInfo.software ?: "").replace("git+", "")
|
||||
Box(modifier = Modifier.padding(start = 10.dp)) {
|
||||
ClickableUrl(
|
||||
|
||||
@@ -47,11 +47,11 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@@ -63,7 +63,7 @@ data class RelayList(
|
||||
|
||||
data class RelayInfoDialog(
|
||||
val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
|
||||
val relayInfo: RelayInformation,
|
||||
val relayInfo: Nip11RelayInformation,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -65,7 +65,7 @@ fun ChannelFabColumn(
|
||||
|
||||
if (wantsToSendNewMessage) {
|
||||
NewPostView(
|
||||
{ wantsToSendNewMessage = false },
|
||||
onClose = { wantsToSendNewMessage = false },
|
||||
enableMessageInterface = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
|
||||
@@ -42,9 +42,9 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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
|
||||
@@ -66,7 +66,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fasterxml.jackson.databind.node.TextNode
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.service.CashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CachedCashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CashuToken
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.CashuIcon
|
||||
@@ -85,25 +85,26 @@ 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
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(
|
||||
cashutoken: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var cachuData by remember {
|
||||
mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading())
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = cashutoken) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newCachuData = CashuProcessor().parse(cashutoken)
|
||||
launch(Dispatchers.Main) { cachuData = newCachuData }
|
||||
val cashuData by produceState(
|
||||
initialValue = CachedCashuProcessor.cached(cashutoken),
|
||||
key1 = cashutoken,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newToken = CachedCashuProcessor.parse(cashutoken)
|
||||
if (value != newToken) {
|
||||
value = newToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = cachuData, label = "CashuPreview(") {
|
||||
Crossfade(targetState = cashuData, label = "CashuPreview") {
|
||||
when (it) {
|
||||
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
|
||||
is GenericLoadable.Error<CashuToken> ->
|
||||
@@ -160,17 +161,24 @@ fun CashuPreview(
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
@@ -193,11 +201,17 @@ fun CashuPreview(
|
||||
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 5.dp).fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
var isRedeeming by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -289,13 +303,17 @@ fun CashuPreviewNew(
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp)
|
||||
.clip(shape = QuoteBorder),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -317,6 +335,7 @@ fun CashuPreviewNew(
|
||||
Text(
|
||||
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
|
||||
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
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.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
@@ -31,7 +30,6 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -60,63 +58,79 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.Nip30CustomEmoji
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
nip19: Nip19.Return,
|
||||
word: String,
|
||||
nip19: Nip19Bech32.ParseReturn,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
when (nip19.type) {
|
||||
Nip19.Type.USER -> {
|
||||
DisplayUser(nip19, accountViewModel, nav)
|
||||
when (val entity = nip19.entity) {
|
||||
is Nip19Bech32.NPub -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NProfile -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.Note -> DisplayEvent(entity.hex, null, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NEmbed -> LoadAndDisplayEvent(entity.event, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NAddress -> DisplayAddress(entity, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NRelay -> {
|
||||
Text(word)
|
||||
}
|
||||
Nip19.Type.ADDRESS -> {
|
||||
DisplayAddress(nip19, accountViewModel, nav)
|
||||
}
|
||||
Nip19.Type.NOTE -> {
|
||||
DisplayNote(nip19, accountViewModel, nav)
|
||||
}
|
||||
Nip19.Type.EVENT -> {
|
||||
DisplayEvent(nip19, accountViewModel, nav)
|
||||
is Nip19Bech32.NSec -> {
|
||||
Text(word)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
remember { "@${nip19.hex}${nip19.additionalChars}" },
|
||||
)
|
||||
Text(word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayEvent(
|
||||
nip19: Nip19.Return,
|
||||
fun LoadOrCreateNote(
|
||||
event: Event,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (Note?) -> Unit,
|
||||
) {
|
||||
var note by
|
||||
remember(event.id) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(event.id)) }
|
||||
|
||||
if (note == null) {
|
||||
LaunchedEffect(key1 = event.id) {
|
||||
accountViewModel.checkGetOrCreateNote(event) { note = it }
|
||||
}
|
||||
}
|
||||
|
||||
content(note)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadAndDisplayEvent(
|
||||
event: Event,
|
||||
additionalChars: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
LoadNote(nip19.hex, accountViewModel) {
|
||||
LoadOrCreateNote(event, accountViewModel) {
|
||||
if (it != null) {
|
||||
DisplayNoteLink(it, nip19, accountViewModel, nav)
|
||||
DisplayNoteLink(it, event.id, event.kind, additionalChars, accountViewModel, nav)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
|
||||
suffix = nip19.additionalChars,
|
||||
route = remember(nip19) { "Event/${nip19.hex}" },
|
||||
clickablePart = remember(event.id) { "@${event.toNIP19()}" },
|
||||
suffix = additionalChars,
|
||||
route = remember(event.id) { "Event/${event.id}" },
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
@@ -124,19 +138,21 @@ private fun DisplayEvent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayNote(
|
||||
nip19: Nip19.Return,
|
||||
private fun DisplayEvent(
|
||||
hex: HexKey,
|
||||
kind: Int?,
|
||||
additionalChars: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
LoadNote(nip19.hex, accountViewModel = accountViewModel) {
|
||||
LoadNote(hex, accountViewModel) {
|
||||
if (it != null) {
|
||||
DisplayNoteLink(it, nip19, accountViewModel, nav)
|
||||
DisplayNoteLink(it, hex, kind, additionalChars, accountViewModel, nav)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
|
||||
suffix = nip19.additionalChars,
|
||||
route = remember(nip19) { "Event/${nip19.hex}" },
|
||||
clickablePart = remember(hex) { "@${hex.toShortenHex()}" },
|
||||
suffix = additionalChars,
|
||||
route = remember(hex) { "Event/$hex" },
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
@@ -146,7 +162,9 @@ private fun DisplayNote(
|
||||
@Composable
|
||||
private fun DisplayNoteLink(
|
||||
it: Note,
|
||||
nip19: Nip19.Return,
|
||||
hex: HexKey,
|
||||
kind: Int?,
|
||||
addedCharts: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -156,21 +174,20 @@ private fun DisplayNoteLink(
|
||||
|
||||
val channelHex = remember(noteState) { note.channelHex() }
|
||||
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars}" }
|
||||
|
||||
if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.KIND) {
|
||||
if (note.event is ChannelCreateEvent || kind == ChannelCreateEvent.KIND) {
|
||||
CreateClickableText(
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Channel/${nip19.hex}" },
|
||||
route = remember(noteState) { "Channel/$hex" },
|
||||
nav = nav,
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.KIND) {
|
||||
} else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) {
|
||||
CreateClickableText(
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route =
|
||||
remember(noteState) { (note.author?.pubkeyHex ?: nip19.hex).let { "RoomByAuthor/$it" } },
|
||||
remember(noteState) { (note.author?.pubkeyHex ?: hex).let { "RoomByAuthor/$it" } },
|
||||
nav = nav,
|
||||
)
|
||||
} else if (channelHex != null) {
|
||||
@@ -192,7 +209,7 @@ private fun DisplayNoteLink(
|
||||
CreateClickableText(
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Event/${nip19.hex}" },
|
||||
route = remember(noteState) { "Event/$hex" },
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
@@ -200,28 +217,28 @@ private fun DisplayNoteLink(
|
||||
|
||||
@Composable
|
||||
private fun DisplayAddress(
|
||||
nip19: Nip19.Return,
|
||||
nip19: Nip19Bech32.NAddress,
|
||||
additionalChars: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) }
|
||||
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.atag)) }
|
||||
|
||||
if (noteBase == null) {
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) { noteBase = it }
|
||||
LaunchedEffect(key1 = nip19.atag) {
|
||||
accountViewModel.checkGetOrCreateAddressableNote(nip19.atag) { noteBase = it }
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
|
||||
val route = remember(noteState) { "Note/${nip19.hex}" }
|
||||
val route = remember(noteState) { "Note/${nip19.atag}" }
|
||||
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars}" }
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = displayName,
|
||||
suffix = addedCharts,
|
||||
suffix = additionalChars,
|
||||
route = route,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -229,35 +246,36 @@ private fun DisplayAddress(
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
remember { "@${nip19.hex}${nip19.additionalChars}" },
|
||||
remember { "@${nip19.atag}$additionalChars" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUser(
|
||||
nip19: Nip19.Return,
|
||||
userHex: HexKey,
|
||||
additionalChars: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var userBase by
|
||||
remember(nip19) {
|
||||
remember(userHex) {
|
||||
mutableStateOf(
|
||||
accountViewModel.getUserIfExists(nip19.hex),
|
||||
accountViewModel.getUserIfExists(userHex),
|
||||
)
|
||||
}
|
||||
|
||||
if (userBase == null) {
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
accountViewModel.checkGetOrCreateUser(nip19.hex) { userBase = it }
|
||||
LaunchedEffect(key1 = userHex) {
|
||||
accountViewModel.checkGetOrCreateUser(userHex) { userBase = it }
|
||||
}
|
||||
}
|
||||
|
||||
userBase?.let { RenderUserAsClickableText(it, nip19, nav) }
|
||||
userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) }
|
||||
|
||||
if (userBase == null) {
|
||||
Text(
|
||||
remember { "@${nip19.hex}${nip19.additionalChars}" },
|
||||
remember { "@${userHex}$additionalChars" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -265,7 +283,7 @@ private fun DisplayUser(
|
||||
@Composable
|
||||
private fun RenderUserAsClickableText(
|
||||
baseUser: User,
|
||||
nip19: Nip19.Return,
|
||||
additionalChars: String,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
@@ -282,12 +300,15 @@ private fun RenderUserAsClickableText(
|
||||
userDisplayName?.let {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = it,
|
||||
suffix = nip19.additionalChars,
|
||||
maxLines = 1,
|
||||
route = route,
|
||||
nav = nav,
|
||||
tags = userTags,
|
||||
)
|
||||
|
||||
additionalChars.ifBlank { null }?.let {
|
||||
Text(text = it, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +319,7 @@ fun CreateClickableText(
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
route: String,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -308,12 +330,14 @@ fun CreateClickableText(
|
||||
remember(clickablePart, suffix) {
|
||||
val clickablePartStyle =
|
||||
SpanStyle(
|
||||
fontSize = fontSize,
|
||||
color = overrideColor ?: primaryColor,
|
||||
fontWeight = fontWeight,
|
||||
)
|
||||
|
||||
val nonClickablePartStyle =
|
||||
SpanStyle(
|
||||
fontSize = fontSize,
|
||||
color = overrideColor ?: onBackgroundColor,
|
||||
fontWeight = fontWeight,
|
||||
)
|
||||
@@ -368,6 +392,74 @@ fun ClickableText(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomEmojiChecker(
|
||||
text: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
onRegularText: @Composable (String) -> Unit,
|
||||
onEmojiText: @Composable (ImmutableList<Nip30CustomEmoji.Renderable>) -> Unit,
|
||||
) {
|
||||
val mayContainEmoji by remember(text, tags) {
|
||||
mutableStateOf(Nip30CustomEmoji.fastMightContainEmoji(text, tags))
|
||||
}
|
||||
|
||||
if (mayContainEmoji) {
|
||||
var emojiList by
|
||||
remember(text, tags) {
|
||||
mutableStateOf<ImmutableList<Nip30CustomEmoji.Renderable>?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(text, tags) {
|
||||
val newEmojiList = Nip30CustomEmoji.assembleAnnotatedList(text, tags)
|
||||
if (newEmojiList != null) {
|
||||
emojiList = newEmojiList
|
||||
}
|
||||
}
|
||||
|
||||
emojiList?.let {
|
||||
onEmojiText(it)
|
||||
} ?: run {
|
||||
onRegularText(text)
|
||||
}
|
||||
} else {
|
||||
onRegularText(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomEmojiChecker(
|
||||
text: String,
|
||||
emojis: ImmutableMap<String, String>,
|
||||
onRegularText: @Composable (String) -> Unit,
|
||||
onEmojiText: @Composable (ImmutableList<Nip30CustomEmoji.Renderable>) -> Unit,
|
||||
) {
|
||||
val mayContainEmoji by remember(text, emojis) {
|
||||
mutableStateOf(Nip30CustomEmoji.fastMightContainEmoji(text, emojis))
|
||||
}
|
||||
|
||||
if (mayContainEmoji) {
|
||||
var emojiList by
|
||||
remember(text, emojis) {
|
||||
mutableStateOf<ImmutableList<Nip30CustomEmoji.Renderable>?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(text, emojis) {
|
||||
val newEmojiList = Nip30CustomEmoji.assembleAnnotatedList(text, emojis)
|
||||
if (newEmojiList != null) {
|
||||
emojiList = newEmojiList
|
||||
}
|
||||
}
|
||||
|
||||
emojiList?.let {
|
||||
onEmojiText(it)
|
||||
} ?: run {
|
||||
onRegularText(text)
|
||||
}
|
||||
} else {
|
||||
onRegularText(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
@@ -380,52 +472,40 @@ fun CreateTextWithEmoji(
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = text) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] }
|
||||
?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList = assembleAnnotatedList(text, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textColor =
|
||||
color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } }
|
||||
|
||||
if (emojiList.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
val style =
|
||||
LocalTextStyle.current
|
||||
.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Unspecified,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
),
|
||||
)
|
||||
.toSpanStyle()
|
||||
CustomEmojiChecker(
|
||||
text,
|
||||
tags,
|
||||
onEmojiText = {
|
||||
val style =
|
||||
LocalTextStyle.current
|
||||
.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Unspecified,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
),
|
||||
)
|
||||
.toSpanStyle()
|
||||
|
||||
InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier)
|
||||
}
|
||||
InLineIconRenderer(it, style, fontSize, maxLines, overflow, modifier)
|
||||
},
|
||||
onRegularText = {
|
||||
Text(
|
||||
text = it,
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -440,51 +520,43 @@ fun CreateTextWithEmoji(
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
LaunchedEffect(key1 = text) {
|
||||
launch(Dispatchers.Default) {
|
||||
val newEmojiList = assembleAnnotatedList(text, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textColor =
|
||||
color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } }
|
||||
|
||||
if (emojiList.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
val currentStyle = LocalTextStyle.current
|
||||
val style =
|
||||
remember(currentStyle) {
|
||||
currentStyle
|
||||
.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Unspecified,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
),
|
||||
)
|
||||
.toSpanStyle()
|
||||
}
|
||||
CustomEmojiChecker(
|
||||
text,
|
||||
emojis,
|
||||
onEmojiText = {
|
||||
val currentStyle = LocalTextStyle.current
|
||||
val style =
|
||||
remember(currentStyle) {
|
||||
currentStyle
|
||||
.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Unspecified,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
),
|
||||
)
|
||||
.toSpanStyle()
|
||||
}
|
||||
|
||||
InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier)
|
||||
}
|
||||
InLineIconRenderer(it, style, fontSize, maxLines, overflow, modifier)
|
||||
},
|
||||
onRegularText = {
|
||||
Text(
|
||||
text = it,
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -495,126 +567,62 @@ fun CreateClickableTextWithEmoji(
|
||||
style: TextStyle,
|
||||
onClick: (Int) -> Unit,
|
||||
) {
|
||||
var emojiList by
|
||||
remember(clickablePart) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = clickablePart) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] }
|
||||
?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList = assembleAnnotatedList(clickablePart, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emojiList.isEmpty()) {
|
||||
ClickableText(
|
||||
text = AnnotatedString(clickablePart),
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else {
|
||||
ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) { onClick(it) }
|
||||
}
|
||||
CustomEmojiChecker(
|
||||
text = clickablePart,
|
||||
tags = tags,
|
||||
onRegularText = {
|
||||
ClickableText(
|
||||
text = AnnotatedString(clickablePart),
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
onClick = onClick,
|
||||
)
|
||||
},
|
||||
onEmojiText = {
|
||||
ClickableInLineIconRenderer(it, maxLines, style.toSpanStyle()) { onClick(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class DoubleEmojiList(
|
||||
val part1: ImmutableList<Renderable>,
|
||||
val part2: ImmutableList<Renderable>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
suffix: String?,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
route: String,
|
||||
nav: (String) -> Unit,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
) {
|
||||
var emojiLists by remember(clickablePart) { mutableStateOf<DoubleEmojiList?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = clickablePart) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] }
|
||||
?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
|
||||
val newEmojiList2 =
|
||||
suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList<Renderable>()
|
||||
|
||||
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
|
||||
emojiLists =
|
||||
DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emojiLists == null) {
|
||||
CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav)
|
||||
} else {
|
||||
ClickableInLineIconRenderer(
|
||||
emojiLists!!.part1,
|
||||
maxLines,
|
||||
LocalTextStyle.current
|
||||
.copy(color = overrideColor ?: MaterialTheme.colorScheme.primary, fontWeight = fontWeight)
|
||||
.toSpanStyle(),
|
||||
) {
|
||||
nav(route)
|
||||
}
|
||||
|
||||
InLineIconRenderer(
|
||||
emojiLists!!.part2,
|
||||
LocalTextStyle.current
|
||||
.copy(
|
||||
color = overrideColor ?: MaterialTheme.colorScheme.onBackground,
|
||||
CustomEmojiChecker(
|
||||
text = clickablePart,
|
||||
tags = tags,
|
||||
onRegularText = {
|
||||
CreateClickableText(it, null, maxLines, overrideColor, fontWeight, fontSize, route, nav)
|
||||
},
|
||||
onEmojiText = {
|
||||
val clickablePartStyle =
|
||||
SpanStyle(
|
||||
fontSize = fontSize,
|
||||
color = overrideColor ?: MaterialTheme.colorScheme.primary,
|
||||
fontWeight = fontWeight,
|
||||
)
|
||||
.toSpanStyle(),
|
||||
maxLines = maxLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun assembleAnnotatedList(
|
||||
text: String,
|
||||
emojis: Map<String, String>,
|
||||
): ImmutableList<Renderable> {
|
||||
return Nip30CustomEmoji()
|
||||
.buildArray(text)
|
||||
.map {
|
||||
val url = emojis[it]
|
||||
if (url != null) {
|
||||
ImageUrlType(url)
|
||||
} else {
|
||||
TextType(it)
|
||||
ClickableInLineIconRenderer(
|
||||
it,
|
||||
maxLines,
|
||||
clickablePartStyle,
|
||||
) {
|
||||
nav(route)
|
||||
}
|
||||
}
|
||||
.toImmutableList()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable open class Renderable()
|
||||
|
||||
@Immutable class TextType(val text: String) : Renderable()
|
||||
|
||||
@Immutable class ImageUrlType(val url: String) : Renderable()
|
||||
|
||||
@Composable
|
||||
fun ClickableInLineIconRenderer(
|
||||
wordsInOrder: ImmutableList<Renderable>,
|
||||
wordsInOrder: ImmutableList<Nip30CustomEmoji.Renderable>,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
style: SpanStyle,
|
||||
onClick: (Int) -> Unit,
|
||||
@@ -631,7 +639,7 @@ fun ClickableInLineIconRenderer(
|
||||
val inlineContent =
|
||||
wordsInOrder
|
||||
.mapIndexedNotNull { idx, value ->
|
||||
if (value is ImageUrlType) {
|
||||
if (value is Nip30CustomEmoji.ImageUrlType) {
|
||||
Pair(
|
||||
"inlineContent$idx",
|
||||
InlineTextContent(
|
||||
@@ -660,9 +668,9 @@ fun ClickableInLineIconRenderer(
|
||||
withStyle(
|
||||
style,
|
||||
) {
|
||||
if (value is TextType) {
|
||||
if (value is Nip30CustomEmoji.TextType) {
|
||||
append(value.text)
|
||||
} else if (value is ImageUrlType) {
|
||||
} else if (value is Nip30CustomEmoji.ImageUrlType) {
|
||||
appendInlineContent("inlineContent$idx", "[icon]")
|
||||
}
|
||||
}
|
||||
@@ -677,7 +685,7 @@ fun ClickableInLineIconRenderer(
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent,
|
||||
@@ -688,7 +696,7 @@ fun ClickableInLineIconRenderer(
|
||||
|
||||
@Composable
|
||||
fun InLineIconRenderer(
|
||||
wordsInOrder: ImmutableList<Renderable>,
|
||||
wordsInOrder: ImmutableList<Nip30CustomEmoji.Renderable>,
|
||||
style: SpanStyle,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
@@ -707,7 +715,7 @@ fun InLineIconRenderer(
|
||||
val inlineContent =
|
||||
wordsInOrder
|
||||
.mapIndexedNotNull { idx, value ->
|
||||
if (value is ImageUrlType) {
|
||||
if (value is Nip30CustomEmoji.ImageUrlType) {
|
||||
Pair(
|
||||
"inlineContent$idx",
|
||||
InlineTextContent(
|
||||
@@ -737,9 +745,9 @@ fun InLineIconRenderer(
|
||||
withStyle(
|
||||
style,
|
||||
) {
|
||||
if (value is TextType) {
|
||||
if (value is Nip30CustomEmoji.TextType) {
|
||||
append(value.text)
|
||||
} else if (value is ImageUrlType) {
|
||||
} else if (value is Nip30CustomEmoji.ImageUrlType) {
|
||||
appendInlineContent("inlineContent$idx", "[icon]")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +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.commons.ExpandableTextCutOffCalculator
|
||||
import com.vitorpamplona.amethyst.ui.note.getGradient
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
@@ -63,7 +63,7 @@ fun ExpandableRichTextViewer(
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
val whereToCut = remember(content) { ExpandableTextParser.computeWhereToCutIfPostIsTooLong(content) }
|
||||
val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) }
|
||||
|
||||
val text by
|
||||
remember(content) {
|
||||
|
||||
@@ -34,10 +34,9 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
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
|
||||
@@ -52,43 +51,30 @@ import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser
|
||||
import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
||||
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
|
||||
|
||||
@Stable data class InvoiceAmount(val invoice: String, val amount: String?)
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun LoadValueFromInvoice(
|
||||
lnbcWord: String,
|
||||
inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit,
|
||||
) {
|
||||
var lnInvoice by remember { mutableStateOf<InvoiceAmount?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnbcWord) {
|
||||
launch(Dispatchers.IO) {
|
||||
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
|
||||
if (myInvoice != null) {
|
||||
val myInvoiceAmount =
|
||||
try {
|
||||
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
|
||||
val lnInvoice by
|
||||
produceState(initialValue = CachedLnInvoiceParser.cached(lnbcWord), key1 = lnbcWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newLnInvoice = CachedLnInvoiceParser.parse(lnbcWord)
|
||||
if (value != newLnInvoice) {
|
||||
value = newLnInvoice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner(lnInvoice)
|
||||
}
|
||||
@@ -128,17 +114,24 @@ fun InvoicePreview(
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
@@ -162,12 +155,18 @@ fun InvoicePreview(
|
||||
text = "$it ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = { payViaIntent(lnInvoice, context) { showErrorMessageDialog = it } },
|
||||
shape = QuoteBorder,
|
||||
colors =
|
||||
|
||||
@@ -24,15 +24,15 @@ import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun LoadUrlPreview(
|
||||
@@ -45,19 +45,16 @@ fun LoadUrlPreview(
|
||||
if (!automaticallyShowUrlPreview) {
|
||||
ClickableUrl(urlText, url)
|
||||
} else {
|
||||
var urlPreviewState by
|
||||
remember(url) {
|
||||
mutableStateOf(
|
||||
UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading,
|
||||
)
|
||||
val urlPreviewState by
|
||||
produceState(
|
||||
initialValue = UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading,
|
||||
key1 = url,
|
||||
) {
|
||||
if (value == UrlPreviewState.Loading) {
|
||||
accountViewModel.urlPreview(url) { value = it }
|
||||
}
|
||||
}
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are
|
||||
// created).
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
LaunchedEffect(url) { accountViewModel.urlPreview(url) { urlPreviewState = it } }
|
||||
}
|
||||
|
||||
Crossfade(
|
||||
targetState = urlPreviewState,
|
||||
animationSpec = tween(durationMillis = 100),
|
||||
@@ -68,19 +65,17 @@ fun LoadUrlPreview(
|
||||
if (state.previewInfo.mimeType.type == "image") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
ZoomableUrlImage(url),
|
||||
persistentListOf(),
|
||||
content = MediaUrlImage(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else if (state.previewInfo.mimeType.type == "video") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
ZoomableUrlVideo(url),
|
||||
persistentListOf(),
|
||||
content = MediaUrlVideo(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -22,10 +22,11 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@@ -34,7 +35,7 @@ class MarkdownParser {
|
||||
tag: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Pair<String, String>? {
|
||||
val matcher = tagIndex.matcher(tag)
|
||||
val matcher = RichTextParser.tagIndex.matcher(tag)
|
||||
val (index, suffix) =
|
||||
try {
|
||||
matcher.find()
|
||||
@@ -64,67 +65,88 @@ class MarkdownParser {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>? {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
LocalCache.users[nip19.hex]?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
private suspend fun getDisplayNameFromNip19(nip19: Nip19Bech32.Entity): Pair<String, String>? {
|
||||
return when (nip19) {
|
||||
is Nip19Bech32.NSec -> null
|
||||
is Nip19Bech32.NPub -> {
|
||||
LocalCache.getUserIfExists(nip19.hex)?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
LocalCache.notes[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
is Nip19Bech32.NProfile -> {
|
||||
LocalCache.getUserIfExists(nip19.hex)?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.addressables[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
is Nip19Bech32.Note -> {
|
||||
LocalCache.getNoteIfExists(nip19.hex)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
LocalCache.notes[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
is Nip19Bech32.NEvent -> {
|
||||
LocalCache.getNoteIfExists(nip19.hex)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NEmbed -> {
|
||||
if (LocalCache.getNoteIfExists(nip19.event.id) == null) {
|
||||
LocalCache.verifyAndConsume(nip19.event, null)
|
||||
}
|
||||
|
||||
return null
|
||||
LocalCache.getNoteIfExists(nip19.event.id)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NRelay -> null
|
||||
is Nip19Bech32.NAddress -> {
|
||||
LocalCache.getAddressableNoteIfExists(nip19.atag)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun returnNIP19References(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
): List<Nip19.Return> {
|
||||
): List<Nip19Bech32.Entity> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val listOfReferences = mutableListOf<Nip19.Return>()
|
||||
val listOfReferences = mutableListOf<Nip19Bech32.Entity>()
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
parsedNip19?.let { listOfReferences.add(it) }
|
||||
if (RichTextParser.startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19Bech32.uriToRoute(word)
|
||||
parsedNip19?.let { listOfReferences.add(it.entity) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags?.lists?.forEach {
|
||||
if (it[0] == "p" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
|
||||
listOfReferences.add(Nip19Bech32.NProfile(it[1], listOfNotNull(it.getOrNull(2))))
|
||||
} else if (it[0] == "e" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, ""))
|
||||
listOfReferences.add(Nip19Bech32.NEvent(it[1], listOfNotNull(it.getOrNull(2)), null, null))
|
||||
} else if (it[0] == "a" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, ""))
|
||||
ATag.parseAtag(it[1], it.getOrNull(2))?.let { atag ->
|
||||
listOfReferences.add(Nip19Bech32.NAddress(it[1], listOfNotNull(atag.relay), atag.pubKeyHex, atag.kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return listOfReferences
|
||||
}
|
||||
|
||||
fun returnMarkdownWithSpecialContent(
|
||||
suspend fun returnMarkdownWithSpecialContent(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word)
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
if (RichTextParser.isValidURL(word)) {
|
||||
if (RichTextParser.isImageUrl(word)) {
|
||||
returnContent += " "
|
||||
} else {
|
||||
returnContent += "[$word]($word) "
|
||||
@@ -133,11 +155,11 @@ class MarkdownParser {
|
||||
returnContent += "[$word](mailto:$word) "
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
returnContent += "[$word](tel:$word) "
|
||||
} else if (startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
} else if (RichTextParser.startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19Bech32.uriToRoute(word)
|
||||
returnContent +=
|
||||
if (parsedNip19 !== null) {
|
||||
val pair = getDisplayNameFromNip19(parsedNip19)
|
||||
if (parsedNip19?.entity !== null) {
|
||||
val pair = getDisplayNameFromNip19(parsedNip19.entity)
|
||||
if (pair != null) {
|
||||
val (displayName, nip19) = pair
|
||||
"[$displayName](nostr:$nip19) "
|
||||
@@ -148,15 +170,15 @@ class MarkdownParser {
|
||||
"$word "
|
||||
}
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
if (RichTextParser.tagIndex.matcher(word).matches() && tags != null) {
|
||||
val pair = getDisplayNameAndNIP19FromTag(word, tags)
|
||||
if (pair != null) {
|
||||
returnContent += "[${pair.first}](nostr:${pair.second}) "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
} else if (RichTextParser.hashTagsPattern.matcher(word).matches()) {
|
||||
val hashtagMatcher = RichTextParser.hashTagsPattern.matcher(word)
|
||||
|
||||
val (myTag, mySuffix) =
|
||||
try {
|
||||
|
||||
@@ -65,34 +65,38 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.material3.Material3RichText
|
||||
import com.vitorpamplona.amethyst.commons.BechSegment
|
||||
import com.vitorpamplona.amethyst.commons.CashuSegment
|
||||
import com.vitorpamplona.amethyst.commons.EmailSegment
|
||||
import com.vitorpamplona.amethyst.commons.EmojiSegment
|
||||
import com.vitorpamplona.amethyst.commons.HashIndexEventSegment
|
||||
import com.vitorpamplona.amethyst.commons.HashIndexUserSegment
|
||||
import com.vitorpamplona.amethyst.commons.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.ImageSegment
|
||||
import com.vitorpamplona.amethyst.commons.InvoiceSegment
|
||||
import com.vitorpamplona.amethyst.commons.LinkSegment
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.PhoneSegment
|
||||
import com.vitorpamplona.amethyst.commons.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.SchemelessUrlSegment
|
||||
import com.vitorpamplona.amethyst.commons.Segment
|
||||
import com.vitorpamplona.amethyst.commons.WithdrawSegment
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.HashtagIcon
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.BechSegment
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.CashuSegment
|
||||
import com.vitorpamplona.amethyst.service.EmailSegment
|
||||
import com.vitorpamplona.amethyst.service.EmojiSegment
|
||||
import com.vitorpamplona.amethyst.service.HashIndexEventSegment
|
||||
import com.vitorpamplona.amethyst.service.HashIndexUserSegment
|
||||
import com.vitorpamplona.amethyst.service.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.service.ImageSegment
|
||||
import com.vitorpamplona.amethyst.service.InvoiceSegment
|
||||
import com.vitorpamplona.amethyst.service.LinkSegment
|
||||
import com.vitorpamplona.amethyst.service.PhoneSegment
|
||||
import com.vitorpamplona.amethyst.service.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.service.RichTextParser
|
||||
import com.vitorpamplona.amethyst.service.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.service.SchemelessUrlSegment
|
||||
import com.vitorpamplona.amethyst.service.Segment
|
||||
import com.vitorpamplona.amethyst.service.WithdrawSegment
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
@@ -101,50 +105,16 @@ 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.uriToRoute
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
|
||||
|
||||
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
|
||||
val hashTagsPattern: Pattern =
|
||||
Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
fun removeQueryParamsForExtensionComparison(fullUrl: String): String {
|
||||
return if (fullUrl.contains("?")) {
|
||||
fullUrl.split("?")[0].lowercase()
|
||||
} else if (fullUrl.contains("#")) {
|
||||
fullUrl.split("#")[0].lowercase()
|
||||
} else {
|
||||
fullUrl.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
fun isImageOrVideoUrl(url: String): Boolean {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
|
||||
return imageExtensions.any { removedParamsFromUrl.endsWith(it) } ||
|
||||
videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
fun isValidURL(url: String?): Boolean {
|
||||
return try {
|
||||
URL(url).toURI()
|
||||
true
|
||||
} catch (e: MalformedURLException) {
|
||||
false
|
||||
} catch (e: URISyntaxException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isMarkdown(content: String): Boolean {
|
||||
return content.startsWith("> ") ||
|
||||
@@ -174,15 +144,9 @@ fun RichTextViewer(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview()
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderRegularPreview() {
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
val backgroundColorState =
|
||||
remember {
|
||||
mutableStateOf(backgroundColor)
|
||||
}
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
@@ -209,6 +173,7 @@ fun RenderRegularPreview() {
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
|
||||
@@ -216,27 +181,93 @@ fun RenderRegularPreview() {
|
||||
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)
|
||||
}
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderRegularPreview2() {
|
||||
val nav: (String) -> Unit = {}
|
||||
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
|
||||
fun mockAccountViewModel(): AccountViewModel {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
sharedPreferencesViewModel.init()
|
||||
|
||||
return AccountViewModel(
|
||||
Account(
|
||||
// blank keys
|
||||
keyPair =
|
||||
KeyPair(
|
||||
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
|
||||
pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"),
|
||||
forcePubKeyCheck = false,
|
||||
),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
),
|
||||
sharedPreferencesViewModel.sharedPrefs,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderRegularPreview3() {
|
||||
val tags =
|
||||
ImmutableListOfLists(
|
||||
arrayOf(
|
||||
arrayOf("t", "ioメシヨソイゲーム"),
|
||||
arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"),
|
||||
arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"),
|
||||
arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"),
|
||||
arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"),
|
||||
),
|
||||
)
|
||||
val nav: (String) -> Unit = {}
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
RenderRegular(
|
||||
"\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" +
|
||||
"#ioメシヨソイゲーム\n" +
|
||||
"https://misskey.io/play/9g3qza4jow",
|
||||
tags,
|
||||
) { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,6 +344,17 @@ private fun RenderRegular(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
// UrlPreviews and Images have a 5dp spacing down. This also adds the space to Text.
|
||||
val lastElement = state.paragraphs.lastOrNull()?.words?.lastOrNull()
|
||||
if (lastElement !is ImageSegment &&
|
||||
lastElement !is LinkSegment &&
|
||||
lastElement !is InvoiceSegment &&
|
||||
lastElement !is CashuSegment
|
||||
) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +462,7 @@ fun RenderCustomEmoji(
|
||||
val markdownParseOptions =
|
||||
MarkdownParseOptions(
|
||||
autolink = true,
|
||||
isImage = { url -> isImageOrVideoUrl(url) },
|
||||
isImage = { url -> RichTextParser.isImageOrVideoUrl(url) },
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -455,7 +497,11 @@ private fun RenderContentAsMarkdown(
|
||||
ZoomableContentView(
|
||||
content =
|
||||
remember(destination, tags) {
|
||||
RichTextParser().parseMediaUrl(destination, tags ?: EmptyTagList) ?: ZoomableUrlImage(url = destination)
|
||||
RichTextParser().parseMediaUrl(
|
||||
destination,
|
||||
tags ?: EmptyTagList,
|
||||
title.ifEmpty { null } ?: content,
|
||||
) ?: MediaUrlImage(url = destination, description = title.ifEmpty { null } ?: content)
|
||||
},
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -477,9 +523,7 @@ private fun RefreshableContent(
|
||||
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
|
||||
|
||||
ObserverAllNIP19References(content, tags, accountViewModel) {
|
||||
accountViewModel.returnMarkdownWithSpecialContent(content, tags) {
|
||||
newMarkdownWithSpecialContent,
|
||||
->
|
||||
accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent ->
|
||||
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
|
||||
markdownWithSpecialContent = newMarkdownWithSpecialContent
|
||||
}
|
||||
@@ -496,7 +540,7 @@ fun ObserverAllNIP19References(
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
|
||||
var nip19References by remember(content) { mutableStateOf<List<Nip19Bech32.Entity>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
accountViewModel.returnNIP19References(content, tags) {
|
||||
@@ -510,33 +554,37 @@ fun ObserverAllNIP19References(
|
||||
|
||||
@Composable
|
||||
fun ObserveNIP19(
|
||||
it: Nip19.Return,
|
||||
entity: Nip19Bech32.Entity,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
ObserveNIP19Event(it, accountViewModel, onRefresh)
|
||||
} else if (it.type == Nip19.Type.USER) {
|
||||
ObserveNIP19User(it, accountViewModel, onRefresh)
|
||||
when (val parsed = entity) {
|
||||
is Nip19Bech32.NPub -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NProfile -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.Note -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NEvent -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NEmbed -> ObserveNIP19Event(parsed.event.id, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.NAddress -> ObserveNIP19Event(parsed.atag, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.NSec -> {}
|
||||
is Nip19Bech32.NRelay -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveNIP19Event(
|
||||
it: Nip19.Return,
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var baseNote by remember(it) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(it.hex)) }
|
||||
var baseNote by remember(hex) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(hex)) }
|
||||
|
||||
if (baseNote == null) {
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
if (
|
||||
it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS
|
||||
) {
|
||||
accountViewModel.checkGetOrCreateNote(it.hex) { note ->
|
||||
launch(Dispatchers.Main) { baseNote = note }
|
||||
}
|
||||
LaunchedEffect(key1 = hex) {
|
||||
accountViewModel.checkGetOrCreateNote(hex) { note ->
|
||||
launch(Dispatchers.Main) { baseNote = note }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -560,18 +608,16 @@ fun ObserveNote(
|
||||
|
||||
@Composable
|
||||
private fun ObserveNIP19User(
|
||||
it: Nip19.Return,
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var baseUser by remember(it) { mutableStateOf<User?>(accountViewModel.getUserIfExists(it.hex)) }
|
||||
var baseUser by remember(hex) { mutableStateOf<User?>(accountViewModel.getUserIfExists(hex)) }
|
||||
|
||||
if (baseUser == null) {
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
if (it.type == Nip19.Type.USER) {
|
||||
accountViewModel.checkGetOrCreateUser(it.hex)?.let { user ->
|
||||
launch(Dispatchers.Main) { baseUser = user }
|
||||
}
|
||||
LaunchedEffect(key1 = hex) {
|
||||
accountViewModel.checkGetOrCreateUser(hex)?.let { user ->
|
||||
launch(Dispatchers.Main) { baseUser = user }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -604,7 +650,9 @@ fun BechLink(
|
||||
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
|
||||
|
||||
if (loadedLink == null) {
|
||||
LaunchedEffect(key1 = word) { accountViewModel.parseNIP19(word) { loadedLink = it } }
|
||||
LaunchedEffect(key1 = word) {
|
||||
accountViewModel.parseNIP19(word) { loadedLink = it }
|
||||
}
|
||||
}
|
||||
|
||||
if (canPreview && loadedLink?.baseNote != null) {
|
||||
@@ -614,11 +662,11 @@ fun BechLink(
|
||||
accountViewModel,
|
||||
backgroundColor,
|
||||
nav,
|
||||
loadedLink!!,
|
||||
loadedLink?.nip19?.additionalChars?.ifBlank { null },
|
||||
)
|
||||
}
|
||||
} else if (loadedLink?.nip19 != null) {
|
||||
ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav)
|
||||
ClickableRoute(word, loadedLink?.nip19!!, accountViewModel, nav)
|
||||
} else {
|
||||
val text =
|
||||
remember(word) {
|
||||
@@ -639,7 +687,7 @@ private fun DisplayFullNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
loadedLink: LoadedBechLink,
|
||||
extraChars: String?,
|
||||
) {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
@@ -650,8 +698,6 @@ private fun DisplayFullNote(
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
val extraChars = remember(loadedLink) { loadedLink.nip19.additionalChars.ifBlank { null } }
|
||||
|
||||
extraChars?.let {
|
||||
Text(
|
||||
it,
|
||||
@@ -667,23 +713,25 @@ fun HashTag(
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val background = MaterialTheme.colorScheme.onBackground
|
||||
val hashtagIcon: HashtagIcon? =
|
||||
remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) }
|
||||
remember(segment.segmentText) { checkForHashtagWithIcon(segment.hashtag, primary) }
|
||||
|
||||
val regularText = remember { SpanStyle(color = background) }
|
||||
val clickableTextStyle = remember { SpanStyle(color = primary) }
|
||||
|
||||
val annotatedTermsString =
|
||||
remember {
|
||||
remember(segment.segmentText) {
|
||||
buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
append("#${segment.hashtag}")
|
||||
pop()
|
||||
}
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
pop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,7 +782,12 @@ fun TagLink(
|
||||
if (it == null) {
|
||||
Text(text = word.segmentText)
|
||||
} else {
|
||||
Row { DisplayUserFromTag(it, word.extras, nav) }
|
||||
Row {
|
||||
DisplayUserFromTag(it, nav)
|
||||
word.extras?.let {
|
||||
Text(text = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -811,7 +864,6 @@ private fun DisplayNoteFromTag(
|
||||
@Composable
|
||||
private fun DisplayUserFromTag(
|
||||
baseUser: User,
|
||||
addedChars: String?,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val route = remember { "User/${baseUser.pubkeyHex}" }
|
||||
@@ -819,12 +871,11 @@ private fun DisplayUserFromTag(
|
||||
|
||||
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
|
||||
|
||||
Crossfade(targetState = meta) {
|
||||
Crossfade(targetState = meta, label = "DisplayUserFromTag") {
|
||||
Row {
|
||||
val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex }
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = displayName,
|
||||
suffix = addedChars,
|
||||
maxLines = 1,
|
||||
route = route,
|
||||
nav = nav,
|
||||
|
||||
@@ -30,9 +30,10 @@ import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.Robohash
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.utils.Robohash
|
||||
import okio.Buffer
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.nio.charset.Charset
|
||||
|
||||
@Stable
|
||||
@@ -43,14 +44,10 @@ class HashImageFetcher(
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): SourceResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val source =
|
||||
try {
|
||||
val buffer = Buffer()
|
||||
buffer.writeString(
|
||||
Robohash.assemble(data.toString(), isLightTheme),
|
||||
Charset.defaultCharset(),
|
||||
)
|
||||
buffer
|
||||
Robohash.assemble(data.toString(), isLightTheme).byteInputStream(Charset.defaultCharset()).source().buffer()
|
||||
} finally {
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ class SplitItem<T>(val key: T) {
|
||||
class Split<T>() {
|
||||
var items: List<SplitItem<T>> by mutableStateOf(emptyList())
|
||||
|
||||
fun addItem(
|
||||
key: T,
|
||||
percentage: Float,
|
||||
) {
|
||||
val newItem = SplitItem(key)
|
||||
newItem.percentage = percentage
|
||||
this.items = items.plus(newItem)
|
||||
}
|
||||
|
||||
fun addItem(key: T): Int {
|
||||
val wasEqualSplit = isEqualSplit()
|
||||
val newItem = SplitItem(key)
|
||||
|
||||
@@ -69,7 +69,6 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.SideEffect
|
||||
@@ -105,8 +104,15 @@ import androidx.core.view.ViewCompat
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.MediaLocalImage
|
||||
import com.vitorpamplona.amethyst.commons.MediaLocalVideo
|
||||
import com.vitorpamplona.amethyst.commons.MediaPreloadedContent
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
@@ -137,104 +143,17 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
import java.io.File
|
||||
|
||||
@Immutable
|
||||
abstract class ZoomableContent(
|
||||
val description: String? = null,
|
||||
val dim: String? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
abstract class ZoomableUrlContent(
|
||||
val url: String,
|
||||
description: String? = null,
|
||||
val hash: String? = null,
|
||||
dim: String? = null,
|
||||
val uri: String? = null,
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
@Immutable
|
||||
class ZoomableUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
val blurhash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
class ZoomableUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null,
|
||||
val blurhash: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
abstract class ZoomablePreloadedContent(
|
||||
val localFile: File?,
|
||||
description: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val isVerified: Boolean? = null,
|
||||
dim: String? = null,
|
||||
val uri: String,
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
@Immutable
|
||||
class ZoomableLocalImage(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
val blurhash: String? = null,
|
||||
dim: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String,
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
@Immutable
|
||||
class ZoomableLocalVideo(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
dim: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null,
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
|
||||
return if (isImage) {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
} else if (isVideo) {
|
||||
ZoomableUrlVideo(fullUrl)
|
||||
} else {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZoomableContentView(
|
||||
content: ZoomableContent,
|
||||
images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList(),
|
||||
content: BaseMediaContent,
|
||||
images: ImmutableList<BaseMediaContent> = remember(content) { listOf(content).toImmutableList() },
|
||||
roundedCorner: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember { mutableStateOf(false) }
|
||||
var dialogOpen by remember(content) { mutableStateOf(false) }
|
||||
|
||||
// store the dialog open or close state
|
||||
val shareOpen = remember { mutableStateOf(false) }
|
||||
@@ -250,13 +169,13 @@ fun ZoomableContentView(
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
if (content is ZoomableUrlContent) {
|
||||
if (content is MediaUrlContent) {
|
||||
mainImageModifier =
|
||||
mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { shareOpen.value = true },
|
||||
)
|
||||
} else if (content is ZoomablePreloadedContent) {
|
||||
} else if (content is MediaPreloadedContent) {
|
||||
mainImageModifier =
|
||||
mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
@@ -267,11 +186,11 @@ fun ZoomableContentView(
|
||||
}
|
||||
|
||||
when (content) {
|
||||
is ZoomableUrlImage ->
|
||||
is MediaUrlImage ->
|
||||
SensitivityWarning(content.contentWarning != null, accountViewModel) {
|
||||
UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel)
|
||||
}
|
||||
is ZoomableUrlVideo ->
|
||||
is MediaUrlVideo ->
|
||||
SensitivityWarning(content.contentWarning != null, accountViewModel) {
|
||||
VideoView(
|
||||
videoUri = content.url,
|
||||
@@ -286,9 +205,9 @@ fun ZoomableContentView(
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
is ZoomableLocalImage ->
|
||||
is MediaLocalImage ->
|
||||
LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel)
|
||||
is ZoomableLocalVideo ->
|
||||
is MediaLocalVideo ->
|
||||
content.localFile?.let {
|
||||
VideoView(
|
||||
videoUri = it.toUri().toString(),
|
||||
@@ -310,13 +229,13 @@ fun ZoomableContentView(
|
||||
|
||||
@Composable
|
||||
private fun LocalImageView(
|
||||
content: ZoomableLocalImage,
|
||||
content: MediaLocalImage,
|
||||
mainImageModifier: Modifier,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwayShowImage: Boolean = false,
|
||||
) {
|
||||
if (content.localFile != null && content.localFile.exists()) {
|
||||
if (content.localFileExists()) {
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val showImage =
|
||||
remember {
|
||||
@@ -377,7 +296,7 @@ private fun LocalImageView(
|
||||
|
||||
@Composable
|
||||
private fun UrlImageView(
|
||||
content: ZoomableUrlImage,
|
||||
content: MediaUrlImage,
|
||||
mainImageModifier: Modifier,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -386,7 +305,7 @@ private fun UrlImageView(
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val showImage =
|
||||
remember {
|
||||
mutableStateOf<Boolean>(
|
||||
mutableStateOf(
|
||||
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
|
||||
)
|
||||
}
|
||||
@@ -394,19 +313,9 @@ private fun UrlImageView(
|
||||
val myModifier =
|
||||
remember {
|
||||
mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight)
|
||||
/* Is this necessary? It makes images bleed into other pages
|
||||
.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, false)
|
||||
} ?: this
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
val contentScale =
|
||||
remember {
|
||||
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
val verifierModifier =
|
||||
if (topPaddingForControllers.isSpecified) {
|
||||
@@ -423,7 +332,9 @@ private fun UrlImageView(
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onState = { painterState.value = it },
|
||||
onState = {
|
||||
painterState.value = it
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -457,11 +368,13 @@ fun ImageUrlWithDownloadButton(
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToImage", "")
|
||||
append("$url ")
|
||||
pop()
|
||||
}
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToImage", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
pop()
|
||||
}
|
||||
|
||||
withStyle(regularText) { append(" ") }
|
||||
@@ -500,7 +413,7 @@ private fun InlineDownloadIcon(showImage: MutableState<Boolean>) =
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun AddedImageFeatures(
|
||||
painter: MutableState<AsyncImagePainter.State?>,
|
||||
content: ZoomableLocalImage,
|
||||
content: MediaLocalImage,
|
||||
contentScale: ContentScale,
|
||||
myModifier: Modifier,
|
||||
verifiedModifier: Modifier,
|
||||
@@ -549,8 +462,8 @@ private fun AddedImageFeatures(
|
||||
BlankNote()
|
||||
}
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
if (content.isVerified != null) {
|
||||
HashVerificationSymbol(content.isVerified, verifiedModifier)
|
||||
content.isVerified?.let {
|
||||
HashVerificationSymbol(it, verifiedModifier)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
@@ -562,13 +475,13 @@ private fun AddedImageFeatures(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun AddedImageFeatures(
|
||||
painter: MutableState<AsyncImagePainter.State?>,
|
||||
content: ZoomableUrlImage,
|
||||
content: MediaUrlImage,
|
||||
contentScale: ContentScale,
|
||||
myModifier: Modifier,
|
||||
verifiedModifier: Modifier,
|
||||
showImage: MutableState<Boolean>,
|
||||
) {
|
||||
val ratio = remember { aspectRatio(content.dim) }
|
||||
val ratio = remember(content.url) { aspectRatio(content.dim) }
|
||||
|
||||
if (!showImage.value) {
|
||||
if (content.blurhash != null && ratio != null) {
|
||||
@@ -588,7 +501,7 @@ private fun AddedImageFeatures(
|
||||
ImageUrlWithDownloadButton(content.url, showImage)
|
||||
}
|
||||
} else {
|
||||
var verifiedHash by remember { mutableStateOf<Boolean?>(null) }
|
||||
var verifiedHash by remember(content.url) { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
when (painter.value) {
|
||||
null,
|
||||
@@ -616,10 +529,9 @@ private fun AddedImageFeatures(
|
||||
}
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
if (content.hash != null) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(key1 = content.url) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newVerifiedHash = verifyHash(content, context)
|
||||
val newVerifiedHash = verifyHash(content)
|
||||
if (newVerifiedHash != verifiedHash) {
|
||||
verifiedHash = newVerifiedHash
|
||||
}
|
||||
@@ -657,8 +569,8 @@ fun aspectRatio(dim: String?): Float? {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
||||
var cnt by remember { mutableStateOf<BaseMediaContent?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -671,7 +583,7 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
@@ -683,10 +595,11 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
val annotatedTermsString =
|
||||
remember {
|
||||
buildAnnotatedString {
|
||||
if (content is ZoomableUrlContent) {
|
||||
if (content is MediaUrlContent) {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToImage", "")
|
||||
append(content.url + " ")
|
||||
pop()
|
||||
}
|
||||
} else {
|
||||
withStyle(regularText) { append("Loading content...") }
|
||||
@@ -695,6 +608,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToImage", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
pop()
|
||||
}
|
||||
|
||||
withStyle(regularText) { append(" ") }
|
||||
@@ -705,7 +619,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
|
||||
val pressIndicator =
|
||||
remember {
|
||||
if (content is ZoomableUrlContent) {
|
||||
if (content is MediaUrlContent) {
|
||||
Modifier.clickable { runCatching { uri.openUri(content.url) } }
|
||||
} else {
|
||||
Modifier
|
||||
@@ -757,8 +671,8 @@ fun DisplayBlurHash(
|
||||
|
||||
@Composable
|
||||
fun ZoomableImageDialog(
|
||||
imageUrl: ZoomableContent,
|
||||
allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(),
|
||||
imageUrl: BaseMediaContent,
|
||||
allImages: ImmutableList<BaseMediaContent> = listOf(imageUrl).toImmutableList(),
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
@@ -821,8 +735,8 @@ fun ZoomableImageDialog(
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun DialogContent(
|
||||
allImages: ImmutableList<ZoomableContent>,
|
||||
imageUrl: ZoomableContent,
|
||||
allImages: ImmutableList<BaseMediaContent>,
|
||||
imageUrl: BaseMediaContent,
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
@@ -847,14 +761,16 @@ private fun DialogContent(
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
) { index ->
|
||||
RenderImageOrVideo(
|
||||
content = allImages[index],
|
||||
roundedCorner = false,
|
||||
topPaddingForControllers = Size55dp,
|
||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
||||
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
allImages.getOrNull(index)?.let {
|
||||
RenderImageOrVideo(
|
||||
content = it,
|
||||
roundedCorner = false,
|
||||
topPaddingForControllers = Size55dp,
|
||||
onControllerVisibilityChanged = { controllerVisible.value = it },
|
||||
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RenderImageOrVideo(
|
||||
@@ -879,18 +795,19 @@ private fun DialogContent(
|
||||
) {
|
||||
CloseButton(onPress = onDismiss)
|
||||
|
||||
val myContent = allImages[pagerState.currentPage]
|
||||
if (myContent is ZoomableUrlContent) {
|
||||
Row {
|
||||
CopyToClipboard(content = myContent)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
SaveToGallery(url = myContent.url)
|
||||
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
||||
if (myContent is MediaUrlContent) {
|
||||
Row {
|
||||
CopyToClipboard(content = myContent)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
SaveToGallery(url = myContent.url)
|
||||
}
|
||||
} else if (myContent is MediaLocalImage && myContent.localFileExists()) {
|
||||
SaveToGallery(
|
||||
localFile = myContent.localFile!!,
|
||||
mimeType = myContent.mimeType,
|
||||
)
|
||||
}
|
||||
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
|
||||
SaveToGallery(
|
||||
localFile = myContent.localFile,
|
||||
mimeType = myContent.mimeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -935,7 +852,7 @@ fun InlineCarrousel(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyToClipboard(content: ZoomableContent) {
|
||||
private fun CopyToClipboard(content: BaseMediaContent) {
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedButton(
|
||||
@@ -955,7 +872,7 @@ private fun CopyToClipboard(content: ZoomableContent) {
|
||||
@Composable
|
||||
private fun ShareImageAction(
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
content: ZoomableContent,
|
||||
content: BaseMediaContent,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(
|
||||
@@ -964,7 +881,7 @@ private fun ShareImageAction(
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
if (content is ZoomableUrlContent) {
|
||||
if (content is MediaUrlContent) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_url_to_clipboard)) },
|
||||
onClick = {
|
||||
@@ -972,18 +889,18 @@ private fun ShareImageAction(
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
if (content.uri != null) {
|
||||
content.uri?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(content.uri))
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (content is ZoomablePreloadedContent) {
|
||||
if (content is MediaPreloadedContent) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) },
|
||||
onClick = {
|
||||
@@ -997,7 +914,7 @@ private fun ShareImageAction(
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(
|
||||
content: ZoomableContent,
|
||||
content: BaseMediaContent,
|
||||
roundedCorner: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
@@ -1006,7 +923,7 @@ private fun RenderImageOrVideo(
|
||||
) {
|
||||
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
if (content is MediaUrlImage) {
|
||||
val mainModifier =
|
||||
Modifier.fillMaxSize()
|
||||
.zoomable(
|
||||
@@ -1025,7 +942,7 @@ private fun RenderImageOrVideo(
|
||||
accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
} else if (content is MediaUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoViewInner(
|
||||
videoUri = content.url,
|
||||
@@ -1038,7 +955,7 @@ private fun RenderImageOrVideo(
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
)
|
||||
}
|
||||
} else if (content is ZoomableLocalImage) {
|
||||
} else if (content is MediaLocalImage) {
|
||||
val mainModifier =
|
||||
Modifier.fillMaxSize()
|
||||
.zoomable(
|
||||
@@ -1057,7 +974,7 @@ private fun RenderImageOrVideo(
|
||||
accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
} else if (content is ZoomableLocalVideo) {
|
||||
} else if (content is MediaLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
content.localFile?.let {
|
||||
VideoViewInner(
|
||||
@@ -1076,13 +993,10 @@ private fun RenderImageOrVideo(
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private fun verifyHash(
|
||||
content: ZoomableUrlContent,
|
||||
context: Context,
|
||||
): Boolean? {
|
||||
private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
||||
Amethyst.instance.coilCache.openSnapshot(content.url)?.use { snapshot ->
|
||||
val hash = CryptoUtils.sha256(snapshot.data.toFile().readBytes()).toHexKey()
|
||||
|
||||
Log.d("Image Hash Verification", "$hash == ${content.hash}")
|
||||
|
||||
@@ -33,7 +33,7 @@ class CommunityFeedFilter(val note: AddressableNote, val account: Account) :
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
||||
@@ -36,7 +36,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
||||
@@ -36,7 +36,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
||||
@@ -45,7 +45,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
||||
@@ -50,7 +50,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values, true)
|
||||
val notes = innerApplyFilter(LocalCache.noteListCache, true)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false)
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
|
||||
@@ -52,7 +52,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
@@ -99,8 +99,10 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
|
||||
val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex }
|
||||
val isAuthorDirectlyCited = event.citedUsers().contains(authorHex)
|
||||
val isAuthorOfAFork =
|
||||
event.isForkFromAddressWithPubkey(authorHex) || (event.forkFromVersion()?.let { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } == true)
|
||||
|
||||
return isAuthoredPostCited || isAuthorDirectlyCited
|
||||
return isAuthoredPostCited || isAuthorDirectlyCited || isAuthorOfAFork
|
||||
}
|
||||
|
||||
if (event is ReactionEvent) {
|
||||
|
||||
@@ -36,7 +36,7 @@ class UserProfileConversationsFeedFilter(val user: User, val account: Account) :
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
||||
@@ -30,7 +30,7 @@ class UserProfileFollowersFeedFilter(val user: User, val account: Account) : Fee
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) }
|
||||
return LocalCache.userListCache.filter { it.isFollowing(user) && !account.isHidden(it) }
|
||||
}
|
||||
|
||||
override fun limit() = 400
|
||||
|
||||
@@ -41,7 +41,7 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) :
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val notes = innerApplyFilter(LocalCache.noteListCache)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
|
||||
@@ -43,7 +43,7 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val notes = innerApplyFilter(LocalCache.noteListCache)
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ class AddBountyAmountViewModel : ViewModel() {
|
||||
replyingTo = null,
|
||||
root = null,
|
||||
directMentions = setOf(),
|
||||
forkedFrom = null,
|
||||
)
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
|
||||
@@ -49,15 +49,32 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DisplayZapSplits(
|
||||
noteEvent: EventInterface,
|
||||
useAuthorIfEmpty: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val list = remember(noteEvent) { noteEvent.zapSplitSetup() }
|
||||
val list =
|
||||
remember(noteEvent) {
|
||||
val list = noteEvent.zapSplitSetup()
|
||||
if (list.isEmpty() && useAuthorIfEmpty) {
|
||||
listOf<ZapSplitSetup>(
|
||||
ZapSplitSetup(
|
||||
lnAddressOrPubKeyHex = noteEvent.pubKey(),
|
||||
relay = null,
|
||||
weight = 1.0,
|
||||
isLnAddress = false,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
if (list.isEmpty()) return
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -77,10 +77,8 @@ 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.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -149,12 +147,9 @@ fun DisplayAccount(
|
||||
) {
|
||||
var baseUser by remember {
|
||||
mutableStateOf<User?>(
|
||||
LocalCache.getUserIfExists(
|
||||
decodePublicKey(
|
||||
acc.npub,
|
||||
)
|
||||
.toHexKey(),
|
||||
),
|
||||
decodePublicKeyAsHexOrNull(acc.npub)?.let {
|
||||
LocalCache.getUserIfExists(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,13 +157,8 @@ fun DisplayAccount(
|
||||
LaunchedEffect(key1 = acc.npub) {
|
||||
launch(Dispatchers.IO) {
|
||||
baseUser =
|
||||
try {
|
||||
LocalCache.getOrCreateUser(
|
||||
decodePublicKey(acc.npub).toHexKey(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
decodePublicKeyAsHexOrNull(acc.npub)?.let {
|
||||
LocalCache.getOrCreateUser(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,10 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -45,7 +42,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -58,8 +54,8 @@ import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size0dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.bottomIconModifier
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
val bottomNavigationItems =
|
||||
@@ -207,9 +203,7 @@ fun AddNotifIconIfNeeded(
|
||||
private fun NotificationDotIcon(modifier: Modifier) {
|
||||
Box(modifier.size(Size10dp)) {
|
||||
Box(
|
||||
modifier =
|
||||
remember { Size10Modifier.clip(shape = CircleShape) }
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
modifier = MaterialTheme.colorScheme.bottomIconModifier,
|
||||
contentAlignment = Alignment.TopEnd,
|
||||
) {
|
||||
Text(
|
||||
@@ -217,12 +211,7 @@ private fun NotificationDotIcon(modifier: Modifier) {
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = Font12SP,
|
||||
modifier =
|
||||
remember {
|
||||
Modifier
|
||||
.wrapContentHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
},
|
||||
// modifier = Modifier.wrapContentHeight().align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import coil.Coil
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
@@ -817,15 +816,12 @@ fun SimpleTextSpinner(
|
||||
fun RenderOption(option: Name) {
|
||||
when (option) {
|
||||
is GeoHashName -> {
|
||||
val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull()
|
||||
if (geohash != null) {
|
||||
LoadCityName(geohash) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
LoadCityName(option.geoHashTag) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -979,11 +975,11 @@ fun debugState(context: Context) {
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Notes: " +
|
||||
LocalCache.notes.filter { it.value.liveSet != null }.size +
|
||||
LocalCache.noteListCache.filter { it.liveSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.filter { it.value.event != null }.size +
|
||||
LocalCache.noteListCache.filter { it.event != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.size,
|
||||
LocalCache.noteListCache.size,
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
@@ -997,21 +993,21 @@ fun debugState(context: Context) {
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Users: " +
|
||||
LocalCache.users.filter { it.value.liveSet != null }.size +
|
||||
LocalCache.userListCache.filter { it.liveSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.filter { it.value.info?.latestMetadata != null }.size +
|
||||
LocalCache.userListCache.filter { it.info?.latestMetadata != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.size,
|
||||
LocalCache.userListCache.size,
|
||||
)
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Memory used by Events: " +
|
||||
LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) +
|
||||
LocalCache.noteListCache.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) +
|
||||
" MB",
|
||||
)
|
||||
|
||||
LocalCache.notes.values
|
||||
LocalCache.noteListCache
|
||||
.groupBy { it.event?.kind() }
|
||||
.forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") }
|
||||
LocalCache.addressables.values
|
||||
|
||||
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -32,7 +31,6 @@ import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navArgument
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -240,16 +238,6 @@ sealed class Route(
|
||||
}
|
||||
}
|
||||
|
||||
// **
|
||||
// * Functions below only exist because we have not broken the datasource classes into backend and
|
||||
// frontend.
|
||||
// **
|
||||
@Composable
|
||||
fun currentRoute(navController: NavHostController): String? {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
return navBackStackEntry?.destination?.route
|
||||
}
|
||||
|
||||
open class LatestItem {
|
||||
var newestItemPerAccount: Map<String, Note?> = mapOf()
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ fun BadgeCompose(
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
NoteDropDownMenu(note, popupExpanded, accountViewModel)
|
||||
NoteDropDownMenu(note, popupExpanded, null, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -36,15 +37,28 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun BlankNotePreview() {
|
||||
ThemeComparisonColumn(
|
||||
onDark = { BlankNote() },
|
||||
onLight = { BlankNote() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BlankNote(
|
||||
@@ -74,7 +88,7 @@ fun BlankNote(
|
||||
}
|
||||
|
||||
if (!showDivider) {
|
||||
Divider(
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
@@ -84,6 +98,32 @@ fun BlankNote(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun HiddenNotePreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
ThemeComparisonColumn(
|
||||
onDark = {
|
||||
HiddenNote(
|
||||
reports = persistentSetOf<Note>(),
|
||||
isHiddenAuthor = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {}
|
||||
},
|
||||
onLight = {
|
||||
HiddenNote(
|
||||
reports = persistentSetOf<Note>(),
|
||||
isHiddenAuthor = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun HiddenNote(
|
||||
@@ -148,3 +188,56 @@ fun HiddenNote(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HiddenNoteByMePreview() {
|
||||
ThemeComparisonColumn(
|
||||
onDark = { HiddenNoteByMe {} },
|
||||
onLight = { HiddenNoteByMe {} },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HiddenNoteByMe(
|
||||
modifier: Modifier = Modifier,
|
||||
isQuote: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(30.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.post_was_hidden),
|
||||
color = Color.Gray,
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = onClick,
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
contentPadding = ButtonPadding,
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_anyway), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isQuote) {
|
||||
Divider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,7 +787,6 @@ private fun DisplayMessageUsername(
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userDisplayName,
|
||||
suffix = "",
|
||||
maxLines = 1,
|
||||
tags = userTags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
||||
@@ -129,7 +129,7 @@ fun MessageSetCompose(
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
|
||||
NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
161
app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt
Normal file
161
app/src/main/java/com/vitorpamplona/amethyst/ui/note/MiniFhir.kt
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
|
||||
@JsonTypeInfo(
|
||||
use = JsonTypeInfo.Id.NAME,
|
||||
include = JsonTypeInfo.As.PROPERTY,
|
||||
property = "resourceType",
|
||||
)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(value = Practitioner::class, name = "Practitioner"),
|
||||
JsonSubTypes.Type(value = Patient::class, name = "Patient"),
|
||||
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
|
||||
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
|
||||
)
|
||||
open class Resource(
|
||||
var resourceType: String? = null,
|
||||
var id: String = "",
|
||||
)
|
||||
|
||||
class Practitioner(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
var active: Boolean? = null,
|
||||
var name: ArrayList<HumanName> = arrayListOf(),
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
class Patient(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
var active: Boolean? = null,
|
||||
var name: ArrayList<HumanName> = arrayListOf(),
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
class HumanName(
|
||||
var use: String? = null,
|
||||
var family: String? = null,
|
||||
var given: ArrayList<String> = arrayListOf(),
|
||||
) {
|
||||
fun assembleName(): String {
|
||||
return given.joinToString(" ") + " " + family
|
||||
}
|
||||
}
|
||||
|
||||
class Bundle(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
var type: String? = null,
|
||||
var created: String? = null,
|
||||
var entry: List<Resource> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
class VisionPrescription(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
var status: String? = null,
|
||||
var created: String? = null,
|
||||
var patient: Reference? = Reference(),
|
||||
var encounter: Reference? = Reference(),
|
||||
var dateWritten: String? = null,
|
||||
var prescriber: Reference? = Reference(),
|
||||
var lensSpecification: List<LensSpecification> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
class LensSpecification(
|
||||
var eye: String? = null,
|
||||
var sphere: Double? = null,
|
||||
var cylinder: Double? = null,
|
||||
var axis: Double? = null,
|
||||
var add: Double? = null,
|
||||
var prism: List<Prism> = arrayListOf(),
|
||||
// contact lenses
|
||||
var power: Double? = null,
|
||||
var backCurve: Double? = null,
|
||||
var diameter: Double? = null,
|
||||
var color: String? = null,
|
||||
var brand: String? = null,
|
||||
var note: String? = null,
|
||||
)
|
||||
|
||||
class Prism(
|
||||
var amount: Double? = null,
|
||||
var base: String? = null,
|
||||
)
|
||||
|
||||
class Reference(
|
||||
var reference: String? = null,
|
||||
)
|
||||
|
||||
data class FhirElementDatabase(
|
||||
var baseResource: Resource? = null,
|
||||
var localDb: ImmutableMap<String, Resource> = persistentMapOf(),
|
||||
)
|
||||
|
||||
fun findReferenceInDb(
|
||||
it: String,
|
||||
db: Map<String, Resource>,
|
||||
): Resource? {
|
||||
val parts = it.split("/")
|
||||
return if (parts.size == 2) {
|
||||
db.get(parts[1].removePrefix("#"))
|
||||
} else {
|
||||
db.get(it.removePrefix("#"))
|
||||
}
|
||||
}
|
||||
|
||||
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
return try {
|
||||
val resource = mapper.readValue(json, Resource::class.java)
|
||||
|
||||
val db =
|
||||
when (resource) {
|
||||
is Bundle -> {
|
||||
resource.entry.associateBy { it.id }.toImmutableMap()
|
||||
}
|
||||
else -> {
|
||||
persistentMapOf(resource.id to resource)
|
||||
}
|
||||
}
|
||||
|
||||
FhirElementDatabase(
|
||||
localDb = db,
|
||||
baseResource = resource,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RenderEyeGlassesPrescription", "Parser error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
@@ -75,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
@@ -90,10 +90,10 @@ import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
|
||||
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
|
||||
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
@@ -159,7 +159,7 @@ fun MultiSetCompose(
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = null,
|
||||
modifier = remember { Modifier.padding(top = 5.dp) },
|
||||
modifier = HalfTopPadding,
|
||||
isBoostedNote = true,
|
||||
showHidden = showHidden,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
@@ -167,7 +167,7 @@ fun MultiSetCompose(
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
|
||||
NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav)
|
||||
}
|
||||
|
||||
Divider(
|
||||
@@ -232,10 +232,7 @@ fun RenderLikeGallery(
|
||||
val url = noStartColon.substringAfter(":")
|
||||
|
||||
val renderable =
|
||||
listOf(
|
||||
ImageUrlType(url),
|
||||
)
|
||||
.toImmutableList()
|
||||
persistentListOf(Nip30CustomEmoji.ImageUrlType(url))
|
||||
|
||||
InLineIconRenderer(
|
||||
renderable,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -342,11 +342,9 @@ private fun RenderMainPopup(
|
||||
icon = ImageVector.vectorResource(id = R.drawable.relays),
|
||||
label = stringResource(R.string.broadcast),
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.broadcast(note)
|
||||
// showSelectTextDialog = true
|
||||
onDismiss()
|
||||
}
|
||||
accountViewModel.broadcast(note)
|
||||
// showSelectTextDialog = true
|
||||
onDismiss()
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(
|
||||
|
||||
@@ -68,7 +68,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -101,7 +100,7 @@ fun PollNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel")
|
||||
val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel${baseNote.idHex}")
|
||||
|
||||
pollViewModel.load(accountViewModel.account, baseNote)
|
||||
|
||||
@@ -126,11 +125,9 @@ fun PollNote(
|
||||
) {
|
||||
WatchZapsAndUpdateTallies(baseNote, pollViewModel)
|
||||
|
||||
val tallies by pollViewModel.tallies.collectAsStateWithLifecycle()
|
||||
|
||||
tallies.forEach { poll_op ->
|
||||
pollViewModel.tallies.forEach { option ->
|
||||
OptionNote(
|
||||
poll_op,
|
||||
option,
|
||||
pollViewModel,
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
@@ -167,9 +164,9 @@ private fun OptionNote(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 3.dp),
|
||||
) {
|
||||
if (!pollViewModel.canZap()) {
|
||||
if (!pollViewModel.canZap.value) {
|
||||
val color =
|
||||
if (poolOption.consensusThreadhold) {
|
||||
if (poolOption.consensusThreadhold.value) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.mediumImportanceLink
|
||||
@@ -181,8 +178,7 @@ private fun OptionNote(
|
||||
pollViewModel = pollViewModel,
|
||||
nonClickablePrepend = {
|
||||
RenderOptionAfterVote(
|
||||
poolOption.descriptor,
|
||||
poolOption.tally.toFloat(),
|
||||
poolOption,
|
||||
color,
|
||||
canPreview,
|
||||
tags,
|
||||
@@ -220,8 +216,7 @@ private fun OptionNote(
|
||||
|
||||
@Composable
|
||||
private fun RenderOptionAfterVote(
|
||||
description: String,
|
||||
totalRatio: Float,
|
||||
poolOption: PollOption,
|
||||
color: Color,
|
||||
canPreview: Boolean,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
@@ -229,10 +224,9 @@ private fun RenderOptionAfterVote(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val totalPercentage = remember(totalRatio) { "${(totalRatio * 100).roundToInt()}%" }
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxWidth(0.75f)
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
2.dp,
|
||||
@@ -243,7 +237,9 @@ private fun RenderOptionAfterVote(
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = color,
|
||||
progress = totalRatio,
|
||||
progress = {
|
||||
poolOption.tally.value.toFloat()
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -251,21 +247,31 @@ private fun RenderOptionAfterVote(
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = remember { Modifier.padding(horizontal = 10.dp).width(40.dp) },
|
||||
modifier =
|
||||
remember {
|
||||
Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.width(45.dp)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = totalPercentage,
|
||||
text = "${(poolOption.tally.value.toFloat() * 100).roundToInt()}%",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = remember { Modifier.fillMaxWidth().padding(15.dp) },
|
||||
modifier =
|
||||
remember {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 15.dp, horizontal = 10.dp)
|
||||
},
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
description,
|
||||
poolOption.descriptor,
|
||||
canPreview,
|
||||
remember { Modifier },
|
||||
Modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
@@ -286,7 +292,8 @@ private fun RenderOptionBeforeVote(
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth(0.75f)
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
2.dp,
|
||||
@@ -358,7 +365,7 @@ fun ZapVote(
|
||||
R.string.poll_unable_to_vote,
|
||||
R.string.poll_author_no_vote,
|
||||
)
|
||||
} else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) {
|
||||
} else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn.value) {
|
||||
// only allow one vote per option when min==max, i.e. atomic vote amount specified
|
||||
accountViewModel.toast(
|
||||
R.string.poll_unable_to_vote,
|
||||
@@ -443,7 +450,7 @@ fun ZapVote(
|
||||
|
||||
clickablePrepend()
|
||||
|
||||
if (poolOption.zappedByLoggedIn) {
|
||||
if (poolOption.zappedByLoggedIn.value) {
|
||||
zappingProgress = 1f
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
@@ -471,8 +478,8 @@ fun ZapVote(
|
||||
}
|
||||
|
||||
// only show tallies after a user has zapped note
|
||||
if (!pollViewModel.canZap()) {
|
||||
val amountStr = remember(poolOption.zappedValue) { showAmount(poolOption.zappedValue) }
|
||||
if (!pollViewModel.canZap.value) {
|
||||
val amountStr = remember(poolOption.zappedValue.value) { showAmount(poolOption.zappedValue.value) }
|
||||
Text(
|
||||
text = amountStr,
|
||||
fontSize = Font14SP,
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -36,20 +37,18 @@ import com.vitorpamplona.quartz.events.VALUE_MAXIMUM
|
||||
import com.vitorpamplona.quartz.events.VALUE_MINIMUM
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
@Immutable
|
||||
@Stable
|
||||
data class PollOption(
|
||||
val option: Int,
|
||||
val descriptor: String,
|
||||
val zappedValue: BigDecimal,
|
||||
val tally: BigDecimal,
|
||||
val consensusThreadhold: Boolean,
|
||||
val zappedByLoggedIn: Boolean,
|
||||
var zappedValue: MutableState<BigDecimal> = mutableStateOf(BigDecimal.ZERO),
|
||||
var tally: MutableState<BigDecimal> = mutableStateOf(BigDecimal.ZERO),
|
||||
var consensusThreadhold: MutableState<Boolean> = mutableStateOf(false),
|
||||
var zappedByLoggedIn: MutableState<Boolean> = mutableStateOf(false),
|
||||
)
|
||||
|
||||
@Stable
|
||||
@@ -70,65 +69,67 @@ class PollNoteViewModel : ViewModel() {
|
||||
private var totalZapped: BigDecimal = BigDecimal.ZERO
|
||||
private var wasZappedByLoggedInAccount: Boolean = false
|
||||
|
||||
private val _tallies = MutableStateFlow<List<PollOption>>(emptyList())
|
||||
val tallies = _tallies.asStateFlow()
|
||||
var canZap = mutableStateOf(false)
|
||||
var tallies: List<PollOption> = emptyList()
|
||||
|
||||
fun load(
|
||||
acc: Account,
|
||||
note: Note?,
|
||||
) {
|
||||
account = acc
|
||||
pollNote = note
|
||||
pollEvent = pollNote?.event as PollNoteEvent
|
||||
pollOptions = pollEvent?.pollOptions()
|
||||
valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM)
|
||||
valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM)
|
||||
valueMinimumBD = valueMinimum?.let { BigDecimal(it) }
|
||||
valueMaximumBD = valueMaximum?.let { BigDecimal(it) }
|
||||
consensusThreshold =
|
||||
pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
|
||||
closedAt = pollEvent?.getTagLong(CLOSED_AT)
|
||||
if (acc != account || pollNote != note) {
|
||||
account = acc
|
||||
pollNote = note
|
||||
pollEvent = pollNote?.event as PollNoteEvent
|
||||
pollOptions = pollEvent?.pollOptions()
|
||||
valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM)
|
||||
valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM)
|
||||
valueMinimumBD = valueMinimum?.let { BigDecimal(it) }
|
||||
valueMaximumBD = valueMaximum?.let { BigDecimal(it) }
|
||||
consensusThreshold =
|
||||
pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
|
||||
closedAt = pollEvent?.getTagLong(CLOSED_AT)
|
||||
|
||||
totalZapped = BigDecimal.ZERO
|
||||
wasZappedByLoggedInAccount = false
|
||||
|
||||
canZap.value = checkIfCanZap()
|
||||
|
||||
tallies = pollOptions?.keys?.map { option ->
|
||||
PollOption(
|
||||
option,
|
||||
pollOptions?.get(option) ?: "",
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshTallies() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByLoggedInAccount = false
|
||||
account?.calculateIfNoteWasZappedByAccount(pollNote) { wasZappedByLoggedInAccount = true }
|
||||
account?.calculateIfNoteWasZappedByAccount(pollNote) {
|
||||
wasZappedByLoggedInAccount = true
|
||||
canZap.value = checkIfCanZap()
|
||||
}
|
||||
|
||||
val newOptions =
|
||||
pollOptions?.keys?.map { option ->
|
||||
val zappedInOption = zappedPollOptionAmount(option)
|
||||
tallies.forEach {
|
||||
val zappedValue = zappedPollOptionAmount(it.option)
|
||||
val tallyValue =
|
||||
if (totalZapped > BigDecimal.ZERO) {
|
||||
zappedValue.divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
val myTally =
|
||||
if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||
zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
val cachedZappedByLoggedIn =
|
||||
account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false
|
||||
|
||||
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
|
||||
|
||||
PollOption(
|
||||
option,
|
||||
pollOptions?.get(option) ?: "",
|
||||
zappedInOption,
|
||||
myTally,
|
||||
consensus,
|
||||
cachedZappedByLoggedIn,
|
||||
)
|
||||
}
|
||||
|
||||
_tallies.emit(
|
||||
newOptions ?: emptyList(),
|
||||
)
|
||||
it.zappedValue.value = zappedValue
|
||||
it.tally.value = tallyValue
|
||||
it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!!
|
||||
it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canZap(): Boolean {
|
||||
fun checkIfCanZap(): Boolean {
|
||||
val account = account ?: return false
|
||||
val note = pollNote ?: return false
|
||||
return account.userProfile() != note.author && !wasZappedByLoggedInAccount
|
||||
@@ -209,7 +210,7 @@ class PollNoteViewModel : ViewModel() {
|
||||
return false
|
||||
}
|
||||
|
||||
fun isPollOptionZappedBy(
|
||||
suspend fun isPollOptionZappedBy(
|
||||
option: Int,
|
||||
user: User,
|
||||
onWasZappedByAuthor: () -> Unit,
|
||||
|
||||
@@ -61,6 +61,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -99,9 +100,8 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.TextType
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
@@ -128,6 +128,8 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter
|
||||
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -145,6 +147,7 @@ import kotlin.math.roundToInt
|
||||
fun ReactionsRow(
|
||||
baseNote: Note,
|
||||
showReactionDetail: Boolean,
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -152,7 +155,7 @@ fun ReactionsRow(
|
||||
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav)
|
||||
InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, editState, accountViewModel, nav)
|
||||
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
@@ -169,6 +172,7 @@ private fun InnerReactionRow(
|
||||
baseNote: Note,
|
||||
showReactionDetail: Boolean,
|
||||
wantsToSeeReactions: MutableState<Boolean>,
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -190,6 +194,7 @@ private fun InnerReactionRow(
|
||||
three = {
|
||||
BoostWithDialog(
|
||||
baseNote,
|
||||
editState,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav,
|
||||
@@ -495,23 +500,54 @@ private fun WatchZapAndRenderGallery(
|
||||
@Composable
|
||||
private fun BoostWithDialog(
|
||||
baseNote: Note,
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var wantsToQuote by remember { mutableStateOf<Note?>(null) }
|
||||
var wantsToFork by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
if (wantsToQuote != null) {
|
||||
NewPostView(
|
||||
onClose = { wantsToQuote = null },
|
||||
baseReplyTo = null,
|
||||
quote = wantsToQuote,
|
||||
version = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
BoostReaction(baseNote, grayTint, accountViewModel) { wantsToQuote = baseNote }
|
||||
if (wantsToFork != null) {
|
||||
val replyTo =
|
||||
remember(wantsToFork) {
|
||||
val forkEvent = wantsToFork?.event
|
||||
if (forkEvent is BaseTextNoteEvent) {
|
||||
val hex = forkEvent.replyingTo()
|
||||
wantsToFork?.replyTo?.filter { it.event?.id() == hex }?.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
NewPostView(
|
||||
onClose = { wantsToFork = null },
|
||||
baseReplyTo = replyTo,
|
||||
fork = wantsToFork,
|
||||
version = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
BoostReaction(
|
||||
baseNote,
|
||||
grayTint,
|
||||
accountViewModel,
|
||||
onQuotePress = { wantsToQuote = baseNote },
|
||||
onForkPress = { wantsToFork = baseNote },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -625,7 +661,7 @@ fun TextCount(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlidingAnimationAmount(
|
||||
fun SlidingAnimationAmount(
|
||||
amount: MutableState<String>,
|
||||
textColor: Color,
|
||||
) {
|
||||
@@ -651,6 +687,7 @@ fun BoostReaction(
|
||||
iconSizeModifier: Modifier = Size20Modifier,
|
||||
iconSize: Dp = Size20dp,
|
||||
onQuotePress: () -> Unit,
|
||||
onForkPress: () -> Unit,
|
||||
) {
|
||||
var wantsToBoost by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -672,7 +709,13 @@ fun BoostReaction(
|
||||
wantsToBoost = false
|
||||
onQuotePress()
|
||||
},
|
||||
onRepost = { accountViewModel.boost(baseNote) },
|
||||
onRepost = {
|
||||
accountViewModel.boost(baseNote)
|
||||
},
|
||||
onFork = {
|
||||
wantsToBoost = false
|
||||
onForkPress()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -808,10 +851,9 @@ private fun RenderReactionType(
|
||||
if (reactionType.isNotEmpty() && reactionType[0] == ':') {
|
||||
val renderable =
|
||||
remember(reactionType) {
|
||||
listOf(
|
||||
ImageUrlType(reactionType.removePrefix(":").substringAfter(":")),
|
||||
persistentListOf(
|
||||
Nip30CustomEmoji.ImageUrlType(reactionType.removePrefix(":").substringAfter(":")),
|
||||
)
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
InLineIconRenderer(
|
||||
@@ -1028,7 +1070,7 @@ fun ZapReaction(
|
||||
}
|
||||
}
|
||||
|
||||
private fun zapClick(
|
||||
fun zapClick(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
context: Context,
|
||||
@@ -1065,7 +1107,7 @@ private fun zapClick(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveZapIcon(
|
||||
fun ObserveZapIcon(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
inner: @Composable (MutableState<Boolean>) -> Unit,
|
||||
@@ -1088,7 +1130,7 @@ private fun ObserveZapIcon(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveZapAmountText(
|
||||
fun ObserveZapAmountText(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
inner: @Composable (MutableState<String>) -> Unit,
|
||||
@@ -1151,6 +1193,7 @@ private fun BoostTypeChoicePopup(
|
||||
onDismiss: () -> Unit,
|
||||
onQuote: () -> Unit,
|
||||
onRepost: () -> Unit,
|
||||
onFork: () -> Unit,
|
||||
) {
|
||||
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
|
||||
|
||||
@@ -1191,6 +1234,18 @@ private fun BoostTypeChoicePopup(
|
||||
) {
|
||||
Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = onFork,
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.fork), color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1284,11 +1339,10 @@ private fun ActionableReactionButton(
|
||||
val url = noStartColon.substringAfter(":")
|
||||
|
||||
val renderable =
|
||||
listOf(
|
||||
ImageUrlType(url),
|
||||
TextType(removeSymbol),
|
||||
persistentListOf(
|
||||
Nip30CustomEmoji.ImageUrlType(url),
|
||||
Nip30CustomEmoji.TextType(removeSymbol),
|
||||
)
|
||||
.toImmutableList()
|
||||
|
||||
InLineIconRenderer(
|
||||
renderable,
|
||||
|
||||
@@ -20,11 +20,19 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.layout.Spacer
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -37,14 +45,17 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconButtonModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -58,16 +69,18 @@ fun RelayBadges(
|
||||
|
||||
val relayList by baseNote.live().relayInfo.observeAsState(baseNote.relays)
|
||||
|
||||
Spacer(DoubleVertSpacer)
|
||||
Spacer(StdVertSpacer)
|
||||
|
||||
// FlowRow Seems to be a lot faster than LazyVerticalGrid
|
||||
FlowRow {
|
||||
if (expanded) {
|
||||
relayList?.forEach { RenderRelay(it, accountViewModel, nav) }
|
||||
} else {
|
||||
relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) }
|
||||
relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) }
|
||||
relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) }
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(start = 2.dp, end = 1.dp)) {
|
||||
FlowRow(modifier = Modifier.fillMaxWidth()) {
|
||||
if (expanded) {
|
||||
relayList?.forEach { RenderRelay(it, accountViewModel, nav) }
|
||||
} else {
|
||||
relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) }
|
||||
relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) }
|
||||
relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +89,62 @@ fun RelayBadges(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun RelayIconLayoutPreview() {
|
||||
Column(modifier = Modifier.width(55.dp)) {
|
||||
Spacer(StdVertSpacer)
|
||||
|
||||
// FlowRow Seems to be a lot faster than LazyVerticalGrid
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(start = 2.dp, end = 1.dp)) {
|
||||
FlowRow {
|
||||
Box(
|
||||
modifier = Modifier.size(17.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(17.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(17.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(17.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(17.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(17.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ShowMoreRelaysButton { }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
|
||||
Row(
|
||||
@@ -84,13 +153,11 @@ private fun ShowMoreRelaysButton(onClick: () -> Unit) {
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = ShowMoreRelaysButtonIconButtonModifier,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = stringResource(id = R.string.expand_relay_list),
|
||||
modifier = ShowMoreRelaysButtonIconModifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
@@ -39,6 +38,7 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
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
|
||||
@@ -46,19 +46,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size17dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.relayIconModifier
|
||||
@@ -132,11 +131,27 @@ fun RenderRelay(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
|
||||
val relayInfo by
|
||||
produceState(
|
||||
initialValue = Nip11CachedRetriever.getFromCache(relay.url),
|
||||
) {
|
||||
if (value == null) {
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
relay.url,
|
||||
onInfo = {
|
||||
value = it
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relayInfo != null) {
|
||||
var openRelayDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (openRelayDialog && relayInfo != null) {
|
||||
RelayInformationDialog(
|
||||
onClose = { relayInfo = null },
|
||||
onClose = { openRelayDialog = false },
|
||||
relayInfo = relayInfo!!,
|
||||
relayBriefInfo = relay,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -147,18 +162,12 @@ fun RenderRelay(
|
||||
val context = LocalContext.current
|
||||
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val ripple = rememberRipple(bounded = false, radius = Size15dp)
|
||||
|
||||
val automaticallyShowProfilePicture =
|
||||
remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
val ripple = rememberRipple(bounded = false, radius = Size17dp)
|
||||
|
||||
val clickableModifier =
|
||||
remember(relay) {
|
||||
Modifier
|
||||
.padding(1.dp)
|
||||
.size(Size15dp)
|
||||
.size(Size17dp)
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
@@ -166,7 +175,9 @@ fun RenderRelay(
|
||||
onClick = {
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
relay.url,
|
||||
onInfo = { relayInfo = it },
|
||||
onInfo = {
|
||||
openRelayDialog = true
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg =
|
||||
when (errorCode) {
|
||||
@@ -213,14 +224,18 @@ fun RenderRelay(
|
||||
modifier = clickableModifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
RenderRelayIcon(relay.displayUrl, relay.favIcon, automaticallyShowProfilePicture)
|
||||
RenderRelayIcon(
|
||||
displayUrl = relay.displayUrl,
|
||||
iconUrl = relayInfo?.icon ?: relay.favIcon,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderRelayIcon(
|
||||
displayUrl: String,
|
||||
iconUrl: String,
|
||||
iconUrl: String?,
|
||||
loadProfilePicture: Boolean,
|
||||
iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier,
|
||||
) {
|
||||
|
||||
@@ -71,6 +71,46 @@ fun timeAgo(
|
||||
}
|
||||
}
|
||||
|
||||
fun timeAgoNoDot(
|
||||
time: Long?,
|
||||
context: Context,
|
||||
): String {
|
||||
if (time == null) return " "
|
||||
if (time == 0L) return " ${context.getString(R.string.never)}"
|
||||
|
||||
val timeDifference = TimeUtils.now() - time
|
||||
|
||||
return if (timeDifference > TimeUtils.ONE_YEAR) {
|
||||
// Dec 12, 2022
|
||||
|
||||
if (locale != Locale.getDefault()) {
|
||||
locale = Locale.getDefault()
|
||||
yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale)
|
||||
monthFormatter = SimpleDateFormat("MMM dd", locale)
|
||||
}
|
||||
|
||||
yearFormatter.format(time * 1000)
|
||||
} else if (timeDifference > TimeUtils.ONE_MONTH) {
|
||||
// Dec 12
|
||||
if (locale != Locale.getDefault()) {
|
||||
locale = Locale.getDefault()
|
||||
yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale)
|
||||
monthFormatter = SimpleDateFormat("MMM dd", locale)
|
||||
}
|
||||
|
||||
monthFormatter.format(time * 1000)
|
||||
} else if (timeDifference > TimeUtils.ONE_DAY) {
|
||||
// 2 days
|
||||
(timeDifference / TimeUtils.ONE_DAY).toString() + context.getString(R.string.d)
|
||||
} else if (timeDifference > TimeUtils.ONE_HOUR) {
|
||||
(timeDifference / TimeUtils.ONE_HOUR).toString() + context.getString(R.string.h)
|
||||
} else if (timeDifference > TimeUtils.ONE_MINUTE) {
|
||||
(timeDifference / TimeUtils.ONE_MINUTE).toString() + context.getString(R.string.m)
|
||||
} else {
|
||||
context.getString(R.string.now)
|
||||
}
|
||||
}
|
||||
|
||||
fun timeAgoShort(
|
||||
mills: Long?,
|
||||
stringForNow: String,
|
||||
|
||||
@@ -80,17 +80,17 @@ import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.firstFullChar
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.TextType
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiUrl
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -283,11 +283,10 @@ private fun RenderReactionOption(
|
||||
val url = noStartColon.substringAfter(":")
|
||||
|
||||
val renderable =
|
||||
listOf(
|
||||
ImageUrlType(url),
|
||||
TextType(" ✖"),
|
||||
persistentListOf(
|
||||
Nip30CustomEmoji.ImageUrlType(url),
|
||||
Nip30CustomEmoji.TextType(" ✖"),
|
||||
)
|
||||
.toImmutableList()
|
||||
|
||||
InLineIconRenderer(
|
||||
renderable,
|
||||
|
||||
@@ -87,7 +87,6 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
|
||||
@@ -99,6 +98,8 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -165,18 +166,11 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
|
||||
addedWSS
|
||||
}
|
||||
|
||||
val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null }
|
||||
val privKeyHex =
|
||||
try {
|
||||
unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) }
|
||||
|
||||
if (pubkeyHex != null) {
|
||||
account?.changeZapPaymentRequest(
|
||||
Nip47URI(
|
||||
Nip47WalletConnect.Nip47URI(
|
||||
pubkeyHex,
|
||||
relayUrl,
|
||||
privKeyHex,
|
||||
@@ -207,7 +201,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
|
||||
}
|
||||
|
||||
fun updateNIP47(uri: String) {
|
||||
val contact = Nip47WalletConnectParser.parse(uri)
|
||||
val contact = Nip47WalletConnect.parse(uri)
|
||||
if (contact != null) {
|
||||
walletConnectPubkey = TextFieldValue(contact.pubKeyHex)
|
||||
walletConnectRelay = TextFieldValue(contact.relayUri ?: "")
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -66,11 +67,14 @@ import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -454,7 +458,9 @@ data class DropDownParams(
|
||||
fun NoteDropDownMenu(
|
||||
note: Note,
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
editState: State<GenericLoadable<EditState>>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -473,6 +479,30 @@ fun NoteDropDownMenu(
|
||||
|
||||
val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } }
|
||||
|
||||
val wantsToEditPost =
|
||||
remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToEditPost.value) {
|
||||
// avoids changing while drafting a note and a new event shows up.
|
||||
val versionLookingAt =
|
||||
remember {
|
||||
(editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value
|
||||
}
|
||||
|
||||
EditPostView(
|
||||
onClose = {
|
||||
popupExpanded.value = false
|
||||
wantsToEditPost.value = false
|
||||
},
|
||||
edit = note,
|
||||
versionLookingAt = versionLookingAt,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded.value,
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -551,16 +581,48 @@ fun NoteDropDownMenu(
|
||||
},
|
||||
)
|
||||
Divider()
|
||||
if (note.event is TextNoteEvent) {
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.edit_post)) },
|
||||
onClick = {
|
||||
wantsToEditPost.value = true
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.propose_an_edit)) },
|
||||
onClick = {
|
||||
wantsToEditPost.value = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.broadcast)) },
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.broadcast(note)
|
||||
onDismiss()
|
||||
}
|
||||
accountViewModel.broadcast(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
Divider()
|
||||
if (accountViewModel.account.hasPendingAttestations(note)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.timestamp_pending)) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.timestamp_it)) },
|
||||
onClick = {
|
||||
accountViewModel.timestamp(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.remove_from_private_bookmarks)) },
|
||||
|
||||
@@ -232,7 +232,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
val replies = mutableMapOf<String, Int>()
|
||||
val takenIntoAccount = mutableSetOf<HexKey>()
|
||||
|
||||
LocalCache.notes.values.forEach {
|
||||
LocalCache.noteListCache.forEach {
|
||||
val noteEvent = it.event
|
||||
if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) {
|
||||
if (noteEvent is ReactionEvent) {
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableText
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ZapTheDevsCardPreview() {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
val myCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateTheme(ThemeType.DARK)
|
||||
|
||||
runBlocking(Dispatchers.IO) {
|
||||
val releaseNotes =
|
||||
"""
|
||||
{
|
||||
"id": "0465b20da0adf45dd612024d124e1ed384f7ecd2cd7358e77998828e7bf35fa2",
|
||||
"pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
"created_at": 1708014675,
|
||||
"kind": 1,
|
||||
"tags": [
|
||||
[
|
||||
"p",
|
||||
"ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b",
|
||||
"",
|
||||
"mention"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf",
|
||||
"",
|
||||
"mention"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"Amethyst"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"amethyst"
|
||||
],
|
||||
[
|
||||
"zap",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
"wss://vitor.nostr1.com",
|
||||
"0.6499999761581421"
|
||||
],
|
||||
[
|
||||
"zap",
|
||||
"ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b",
|
||||
"wss://nos.lol",
|
||||
"0.25"
|
||||
],
|
||||
[
|
||||
"zap",
|
||||
"7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf",
|
||||
"wss://vitor.nostr1.com",
|
||||
"0.10000000149011612"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-googleplay-universal-v0.84.2.apk"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-fdroid-universal-v0.84.2.apk"
|
||||
]
|
||||
],
|
||||
"content": "#Amethyst v0.84.2: Text alignment fix\n\nBugfixes:\n- Fixes link misalignment in posts\n\nUpdated translations: \n- Czech, German, Swedish, and Portuguese by nostr:npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef\n- French by nostr:npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-googleplay-universal-v0.84.2.apk )\n- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-fdroid-universal-v0.84.2.apk )",
|
||||
"sig": "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
LocalCache.justConsume(Event.fromJson(releaseNotes), null)
|
||||
}
|
||||
|
||||
val accountViewModel =
|
||||
AccountViewModel(
|
||||
Account(
|
||||
// blank keys
|
||||
keyPair =
|
||||
KeyPair(
|
||||
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
|
||||
pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"),
|
||||
forcePubKeyCheck = false,
|
||||
),
|
||||
scope = myCoroutineScope,
|
||||
),
|
||||
sharedPreferencesViewModel.sharedPrefs,
|
||||
)
|
||||
|
||||
LoadNote(
|
||||
baseNoteHex = "0465b20da0adf45dd612024d124e1ed384f7ecd2cd7358e77998828e7bf35fa2",
|
||||
accountViewModel,
|
||||
) { releaseNote ->
|
||||
if (releaseNote != null) {
|
||||
ThemeComparisonColumn(
|
||||
onDark = {
|
||||
ZapTheDevsCard(
|
||||
releaseNote,
|
||||
accountViewModel,
|
||||
nav = {},
|
||||
)
|
||||
},
|
||||
onLight = {
|
||||
ZapTheDevsCard(
|
||||
releaseNote,
|
||||
accountViewModel,
|
||||
nav = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapTheDevsCard(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val releaseNoteState by baseNote.live().metadata.observeAsState()
|
||||
val releaseNote = releaseNoteState?.note ?: return
|
||||
|
||||
Row(modifier = Modifier.padding(horizontal = Size10dp)) {
|
||||
Card(
|
||||
modifier = MaterialTheme.colorScheme.imageModifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
// Title
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.zap_the_devs_title),
|
||||
style =
|
||||
TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Size20Modifier,
|
||||
onClick = { accountViewModel.markDonatedInThisVersion() },
|
||||
) {
|
||||
CloseIcon()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
ClickableText(
|
||||
text =
|
||||
buildAnnotatedString {
|
||||
append(stringResource(id = R.string.zap_the_devs_description, BuildConfig.VERSION_NAME))
|
||||
append(" ")
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
|
||||
append("#value4value")
|
||||
}
|
||||
},
|
||||
onClick = { nav("Hashtag/value4value") },
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
val noteEvent = releaseNote.event
|
||||
if (noteEvent != null) {
|
||||
val route =
|
||||
remember(releaseNote) {
|
||||
routeFor(releaseNote, accountViewModel.userProfile())
|
||||
}
|
||||
|
||||
if (route != null) {
|
||||
ClickableText(
|
||||
text =
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
|
||||
append(stringResource(id = R.string.version_name, BuildConfig.VERSION_NAME.substringBefore("-")))
|
||||
}
|
||||
append(" " + stringResource(id = R.string.brought_to_you_by))
|
||||
},
|
||||
onClick = { nav(route) },
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(id = R.string.this_version_brought_to_you_by))
|
||||
}
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
DisplayZapSplits(
|
||||
noteEvent = noteEvent,
|
||||
useAuthorIfEmpty = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
|
||||
ZapDonationButton(
|
||||
baseNote = releaseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onPrimary,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapDonationButton(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
iconSize: Dp = Size20dp,
|
||||
iconSizeModifier: Modifier = Size20Modifier,
|
||||
animationSize: Dp = 14.dp,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
|
||||
var wantsToPay by
|
||||
remember(baseNote) {
|
||||
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
|
||||
persistentListOf(),
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var zappingProgress by remember { mutableFloatStateOf(0f) }
|
||||
var hasZapped by remember { mutableStateOf(false) }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
zapClick(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
context,
|
||||
onZappingProgress = { progress: Float ->
|
||||
scope.launch { zappingProgress = progress }
|
||||
},
|
||||
onMultipleChoices = { wantsToZap = true },
|
||||
onError = { _, message ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = message
|
||||
}
|
||||
},
|
||||
onPayViaIntent = { wantsToPay = it },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (wantsToZap) {
|
||||
ZapAmountChoicePopup(
|
||||
baseNote = baseNote,
|
||||
iconSize = iconSize,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = {
|
||||
wantsToZap = false
|
||||
zappingProgress = 0f
|
||||
},
|
||||
onChangeAmount = {
|
||||
wantsToZap = false
|
||||
},
|
||||
onError = { _, message ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = message
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) { zappingProgress = it }
|
||||
},
|
||||
onPayViaIntent = { wantsToPay = it },
|
||||
)
|
||||
}
|
||||
|
||||
if (showErrorMessageDialog != null) {
|
||||
ErrorMessageDialog(
|
||||
title = stringResource(id = R.string.error_dialog_zap_error),
|
||||
textContent = showErrorMessageDialog ?: "",
|
||||
onClickStartMessage = {
|
||||
baseNote.author?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val route = routeToMessage(it, showErrorMessageDialog, accountViewModel)
|
||||
nav(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { showErrorMessageDialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (wantsToPay.isNotEmpty()) {
|
||||
PayViaIntentDialog(
|
||||
payingInvoices = wantsToPay,
|
||||
accountViewModel = accountViewModel,
|
||||
onClose = { wantsToPay = persistentListOf() },
|
||||
onError = {
|
||||
wantsToPay = persistentListOf()
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = iconSizeModifier,
|
||||
) {
|
||||
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
|
||||
Spacer(ModifierWidth3dp)
|
||||
|
||||
CircularProgressIndicator(
|
||||
progress =
|
||||
animateFloatAsState(
|
||||
targetValue = zappingProgress,
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
label = "ZapIconIndicator",
|
||||
)
|
||||
.value,
|
||||
modifier = remember { Modifier.size(animationSize) },
|
||||
strokeWidth = 2.dp,
|
||||
color = grayTint,
|
||||
)
|
||||
} else {
|
||||
ObserveZapIcon(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
) { wasZappedByLoggedInUser ->
|
||||
LaunchedEffect(wasZappedByLoggedInUser.value) {
|
||||
hasZapped = wasZappedByLoggedInUser.value
|
||||
if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) {
|
||||
delay(1000)
|
||||
accountViewModel.markDonatedInThisVersion()
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon") {
|
||||
if (it) {
|
||||
ZappedIcon(iconSizeModifier)
|
||||
} else {
|
||||
ZapIcon(iconSizeModifier, grayTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasZapped) {
|
||||
Text(text = stringResource(id = R.string.thank_you))
|
||||
} else {
|
||||
Text(text = stringResource(id = R.string.donate_now))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,28 +29,14 @@ import com.google.zxing.client.android.Intents
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@Composable
|
||||
fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
|
||||
SimpleQrCodeScanner {
|
||||
try {
|
||||
val nip19 = Nip19.uriToRoute(it)
|
||||
val startingPage =
|
||||
when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> "Event/${nip19.hex}"
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (startingPage != null) {
|
||||
onScan(startingPage)
|
||||
} else {
|
||||
onScan(null)
|
||||
}
|
||||
onScan(uriToRoute(it))
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("NIP19 Scanner", "Error parsing $it", e)
|
||||
|
||||
@@ -33,7 +33,7 @@ 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
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
@@ -85,8 +85,20 @@ class AccountStateViewModel() : ViewModel() {
|
||||
loginWithExternalSigner: Boolean = false,
|
||||
packageName: String = "",
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
val parsed = Nip19Bech32.uriToRoute(key)?.entity
|
||||
val pubKeyParsed =
|
||||
when (parsed) {
|
||||
is Nip19Bech32.NSec -> null
|
||||
is Nip19Bech32.NPub -> parsed.hex.hexToByteArray()
|
||||
is Nip19Bech32.NProfile -> parsed.hex.hexToByteArray()
|
||||
is Nip19Bech32.Note -> null
|
||||
is Nip19Bech32.NEvent -> null
|
||||
is Nip19Bech32.NEmbed -> null
|
||||
is Nip19Bech32.NRelay -> null
|
||||
is Nip19Bech32.NAddress -> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
if (loginWithExternalSigner && pubKeyParsed == null) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
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.setValue
|
||||
@@ -45,10 +46,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapTheDevsCard
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -176,6 +180,10 @@ private fun FeedLoaded(
|
||||
contentPadding = FeedPadding,
|
||||
state = listState,
|
||||
) {
|
||||
item {
|
||||
ShowDonationCard(accountViewModel, nav)
|
||||
}
|
||||
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
|
||||
val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() }
|
||||
|
||||
@@ -192,6 +200,28 @@ private fun FeedLoaded(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowDonationCard(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val account by accountViewModel.account.live.observeAsState()
|
||||
if (account?.account?.hasDonatedInThisVersion() == false) {
|
||||
LoadNote(
|
||||
BuildConfig.RELEASE_NOTES_ID,
|
||||
accountViewModel,
|
||||
) { loadedNoteId ->
|
||||
if (loadedNoteId != null) {
|
||||
ZapTheDevsCard(
|
||||
loadedNoteId,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderCardItem(
|
||||
item: Card,
|
||||
|
||||
@@ -179,7 +179,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
val event = (zapEvent.event as LnZapEvent)
|
||||
val author =
|
||||
event.zappedAuthor().firstNotNullOfOrNull {
|
||||
LocalCache.users[it] // don't create user if it doesn't exist
|
||||
LocalCache.getUserIfExists(it) // don't create user if it doesn't exist
|
||||
}
|
||||
if (author != null) {
|
||||
val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
|
||||
|
||||
@@ -54,7 +54,7 @@ fun RefreshingChatroomFeedView(
|
||||
scrollStateKey: String? = null,
|
||||
enablePullRefresh: Boolean = true,
|
||||
) {
|
||||
RefresheableView(viewModel, enablePullRefresh) {
|
||||
RefresheableBox(viewModel, enablePullRefresh) {
|
||||
SaveableFeedState(viewModel, scrollStateKey) { listState ->
|
||||
RenderChatroomFeedView(
|
||||
viewModel,
|
||||
|
||||
@@ -45,7 +45,7 @@ fun ChatroomListFeedView(
|
||||
nav: (String) -> Unit,
|
||||
markAsRead: MutableState<Boolean>,
|
||||
) {
|
||||
RefresheableView(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) }
|
||||
RefresheableBox(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -27,8 +27,10 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
sealed class FeedState {
|
||||
object Loading : FeedState()
|
||||
|
||||
class Loaded(val feed: MutableState<ImmutableList<Note>>, val showHidden: MutableState<Boolean>) :
|
||||
FeedState()
|
||||
class Loaded(
|
||||
val feed: MutableState<ImmutableList<Note>>,
|
||||
val showHidden: MutableState<Boolean>,
|
||||
) : FeedState()
|
||||
|
||||
object Empty : FeedState()
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -56,6 +57,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@Composable
|
||||
@@ -67,13 +69,15 @@ fun RefresheableFeedView(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
RefresheableView(viewModel, enablePullRefresh) {
|
||||
SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav)
|
||||
RefresheableBox(viewModel, enablePullRefresh) {
|
||||
SaveableFeedState(viewModel, scrollStateKey) { listState ->
|
||||
RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RefresheableView(
|
||||
fun RefresheableBox(
|
||||
viewModel: InvalidatableViewModel,
|
||||
enablePullRefresh: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
@@ -108,19 +112,6 @@ fun RefresheableView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveableFeedState(
|
||||
viewModel: FeedViewModel,
|
||||
routeForLastRead: String?,
|
||||
scrollStateKey: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
SaveableFeedState(viewModel, scrollStateKey) { listState ->
|
||||
RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SaveableFeedState(
|
||||
viewModel: FeedViewModel,
|
||||
@@ -158,12 +149,16 @@ fun SaveableGridFeedState(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderFeed(
|
||||
fun RenderFeedState(
|
||||
viewModel: FeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
listState: LazyListState,
|
||||
nav: (String) -> Unit,
|
||||
routeForLastRead: String?,
|
||||
onLoaded: @Composable (FeedState.Loaded) -> Unit = { FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav) },
|
||||
onEmpty: @Composable () -> Unit = { FeedEmpty { viewModel.invalidateData() } },
|
||||
onError: @Composable (String) -> Unit = { FeedError(it) { viewModel.invalidateData() } },
|
||||
onLoading: @Composable () -> Unit = { LoadingFeed() },
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -172,24 +167,10 @@ private fun RenderFeed(
|
||||
animationSpec = tween(durationMillis = 100),
|
||||
) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty { viewModel.invalidateData() }
|
||||
}
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) { viewModel.invalidateData() }
|
||||
}
|
||||
is FeedState.Loaded -> {
|
||||
FeedLoaded(
|
||||
state = state,
|
||||
listState = listState,
|
||||
routeForLastRead = routeForLastRead,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
is FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
is FeedState.Empty -> onEmpty()
|
||||
is FeedState.FeedError -> onError(state.errorMessage)
|
||||
is FeedState.Loaded -> onLoaded(state)
|
||||
is FeedState.Loading -> onLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,6 +258,7 @@ fun FeedError(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("${stringResource(R.string.error_loading_replies)} $errorMessage")
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Button(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
onClick = onRefresh,
|
||||
@@ -294,6 +276,7 @@ fun FeedEmpty(onRefresh: () -> Unit) {
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(stringResource(R.string.feed_is_empty))
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +328,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) :
|
||||
_feedContent.update { FeedState.Empty }
|
||||
} else if (currentState is FeedState.Loaded) {
|
||||
// updates the current list
|
||||
currentState.showHidden.value = localFilter.showHiddenKey()
|
||||
if (currentState.showHidden.value != localFilter.showHiddenKey()) {
|
||||
currentState.showHidden.value = localFilter.showHiddenKey()
|
||||
}
|
||||
currentState.feed.value = notes
|
||||
} else {
|
||||
_feedContent.update {
|
||||
|
||||
@@ -45,7 +45,7 @@ fun RefreshingFeedStringFeedView(
|
||||
enablePullRefresh: Boolean = true,
|
||||
inner: @Composable (String) -> Unit,
|
||||
) {
|
||||
RefresheableView(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) }
|
||||
RefresheableBox(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -76,34 +76,45 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.components.InlineCarrousel
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost
|
||||
import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost
|
||||
import com.vitorpamplona.amethyst.ui.elements.DisplayPoW
|
||||
import com.vitorpamplona.amethyst.ui.elements.DisplayReward
|
||||
import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits
|
||||
import com.vitorpamplona.amethyst.ui.elements.Reward
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.AudioHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.AudioTrackHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.note.CreateImageHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayEditStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayHighlight
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayLocation
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayOts
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet
|
||||
import com.vitorpamplona.amethyst.ui.note.EditState
|
||||
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.HiddenNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAndDisplayUser
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
|
||||
@@ -112,12 +123,17 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderEmojiPack
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderFhirResource
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderGitPatchEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderPinListEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderPoll
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderPostApproval
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRepost
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderTextEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderTextModificationEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.VideoDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.observeEdits
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -131,6 +147,7 @@ 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.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedNote
|
||||
@@ -144,9 +161,13 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FhirResourceEvent
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.events.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
@@ -154,12 +175,15 @@ import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.VideoEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
@@ -361,6 +385,8 @@ fun NoteMaster(
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
) {
|
||||
val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -391,6 +417,12 @@ fun NoteMaster(
|
||||
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
if (editState.value is GenericLoadable.Loaded) {
|
||||
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.let {
|
||||
DisplayEditStatus(it)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
timeAgo(note.createdAt(), context = context),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
@@ -408,7 +440,7 @@ fun NoteMaster(
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel)
|
||||
NoteDropDownMenu(baseNote, moreActionsExpanded, editState, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +466,8 @@ fun NoteMaster(
|
||||
if (pow > 20) {
|
||||
DisplayPoW(pow)
|
||||
}
|
||||
|
||||
DisplayOts(note, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,6 +478,8 @@ fun NoteMaster(
|
||||
BadgeDisplay(baseNote = note)
|
||||
} else if (noteEvent is LongTextNoteEvent) {
|
||||
RenderLongFormHeaderForThread(noteEvent)
|
||||
} else if (noteEvent is WikiNoteEvent) {
|
||||
RenderWikiHeaderForThread(noteEvent, accountViewModel, nav)
|
||||
} else if (noteEvent is ClassifiedsEvent) {
|
||||
RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel, nav)
|
||||
}
|
||||
@@ -512,6 +548,12 @@ fun NoteMaster(
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else if (noteEvent is FhirResourceEvent) {
|
||||
RenderFhirResource(baseNote, accountViewModel, nav)
|
||||
} else if (noteEvent is GitRepositoryEvent) {
|
||||
RenderGitRepositoryEvent(baseNote, accountViewModel, nav)
|
||||
} else if (noteEvent is GitPatchEvent) {
|
||||
RenderGitPatchEvent(baseNote, false, true, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is AppDefinitionEvent) {
|
||||
RenderAppDefinition(baseNote, accountViewModel, nav)
|
||||
} else if (noteEvent is HighlightEvent) {
|
||||
@@ -528,6 +570,16 @@ fun NoteMaster(
|
||||
)
|
||||
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
|
||||
RenderRepost(baseNote, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is TextNoteModificationEvent) {
|
||||
RenderTextModificationEvent(
|
||||
note = baseNote,
|
||||
makeItShort = false,
|
||||
canPreview = true,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else if (noteEvent is PollNoteEvent) {
|
||||
val canPreview =
|
||||
note.author == account.userProfile() ||
|
||||
@@ -553,6 +605,7 @@ fun NoteMaster(
|
||||
false,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
@@ -564,10 +617,10 @@ fun NoteMaster(
|
||||
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
|
||||
if (zapSplits && noteEvent != null) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
DisplayZapSplits(noteEvent, accountViewModel, nav)
|
||||
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
|
||||
}
|
||||
|
||||
ReactionsRow(note, true, accountViewModel, nav)
|
||||
ReactionsRow(note, true, editState, accountViewModel, nav)
|
||||
|
||||
Divider(
|
||||
thickness = DividerThickness,
|
||||
@@ -797,3 +850,144 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RenderWikiHeaderForThreadPreview() {
|
||||
val event = Event.fromJson("{\"id\":\"277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1708695717,\"kind\":30818,\"tags\":[[\"d\",\"amethyst\"],[\"a\",\"30818:f03e7c5262648e0b7823dfb49f8f17309cfec9cb14711413dcabdf3d7fc6369a:amethyst\",\"wss://relay.nostr.band\",\"fork\"],[\"e\",\"ceabc60c8022c472c727aa25ae7691885964366386ce265c47e5a78be6cb00be\",\"wss://relay.nostr.band\",\"fork\"],[\"title\",\"Amethyst\"],[\"published_at\",\"1708707133\"]],\"content\":\"An Android-only app written in Kotlin with support for over 90 event kinds. \\n\\n\\n\",\"sig\":\"6748126a909a20dbdb67947a09d64e41d7140a79335a4ad675c6173d7dd5dbcab9c360dec617bd67bbbc20dfad416b15056eda2e20716cd6c425a84301a125a0\"}") as WikiNoteEvent
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
val editState =
|
||||
remember {
|
||||
mutableStateOf(GenericLoadable.Empty<EditState>())
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.justConsume(event, null)
|
||||
}
|
||||
}
|
||||
|
||||
LoadNote(baseNoteHex = "277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1", accountViewModel = accountViewModel) { baseNote ->
|
||||
ThemeComparisonColumn(
|
||||
onDark = {
|
||||
val bg = MaterialTheme.colorScheme.background
|
||||
val backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(bg)
|
||||
}
|
||||
|
||||
Column {
|
||||
RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav)
|
||||
RenderTextEvent(
|
||||
baseNote!!,
|
||||
false,
|
||||
true,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
},
|
||||
onLight = {
|
||||
val bg = MaterialTheme.colorScheme.background
|
||||
val backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(bg)
|
||||
}
|
||||
|
||||
Column {
|
||||
RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav)
|
||||
RenderTextEvent(
|
||||
baseNote!!,
|
||||
false,
|
||||
true,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderWikiHeaderForThread(
|
||||
noteEvent: WikiNoteEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() }
|
||||
|
||||
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) {
|
||||
Column {
|
||||
noteEvent.image()?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
R.string.preview_card_image_for,
|
||||
it,
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
noteEvent.title()?.let {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
forkedAddress?.let {
|
||||
LoadAddressableNote(aTag = it, accountViewModel = accountViewModel) { originalVersion ->
|
||||
if (originalVersion != null) {
|
||||
ForkInformationRow(originalVersion, Modifier.fillMaxWidth(), accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noteEvent
|
||||
.summary()
|
||||
?.ifBlank { null }
|
||||
?.let {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.Gray,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ForkInformationRow(
|
||||
originalVersion: Note,
|
||||
modifier: Modifier = Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val noteState by originalVersion.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val author = note.author ?: return
|
||||
val route = remember(note) { routeFor(note, accountViewModel.userProfile()) }
|
||||
|
||||
if (route != null) {
|
||||
Row(modifier) {
|
||||
Text(stringResource(id = R.string.forked_from))
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadAndDisplayUser(author, route, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ fun RefreshingFeedUserFeedView(
|
||||
nav: (String) -> Unit,
|
||||
enablePullRefresh: Boolean = true,
|
||||
) {
|
||||
RefresheableView(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) }
|
||||
RefresheableBox(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -43,7 +43,6 @@ import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
@@ -69,7 +68,8 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.SettingsState
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
@@ -96,6 +96,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.resume
|
||||
@@ -528,7 +529,22 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
}
|
||||
|
||||
fun broadcast(note: Note) {
|
||||
account.broadcast(note)
|
||||
viewModelScope.launch(Dispatchers.IO) { account.broadcast(note) }
|
||||
}
|
||||
|
||||
fun timestamp(note: Note) {
|
||||
viewModelScope.launch(Dispatchers.IO) { account.timestamp(note) }
|
||||
}
|
||||
|
||||
var lastTimeItTriedToUpdateAttestations: Long = 0
|
||||
|
||||
fun upgradeAttestations() {
|
||||
// only tries to upgrade every hour
|
||||
val now = TimeUtils.now()
|
||||
if (now - lastTimeItTriedToUpdateAttestations > TimeUtils.ONE_HOUR) {
|
||||
lastTimeItTriedToUpdateAttestations = now
|
||||
viewModelScope.launch(Dispatchers.IO) { account.updateAttestations() }
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(note: Note) {
|
||||
@@ -647,6 +663,12 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
account.updateShowSensitiveContent(null)
|
||||
}
|
||||
|
||||
fun markDonatedInThisVersion() {
|
||||
viewModelScope.launch {
|
||||
account.markDonatedInThisVersion()
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultZapType(): LnZapEvent.ZapType {
|
||||
return account.defaultZapType
|
||||
}
|
||||
@@ -800,7 +822,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
|
||||
fun retrieveRelayDocument(
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -846,6 +868,22 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateNote(key)) }
|
||||
}
|
||||
|
||||
fun checkGetOrCreateNote(
|
||||
event: Event,
|
||||
onResult: (Note?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
var note = checkGetOrCreateNote(event.id)
|
||||
|
||||
if (note == null) {
|
||||
LocalCache.verifyAndConsume(event, null)
|
||||
note = checkGetOrCreateNote(event.id)
|
||||
}
|
||||
|
||||
onResult(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun getNoteIfExists(hex: HexKey): Note? {
|
||||
return LocalCache.getNoteIfExists(hex)
|
||||
}
|
||||
@@ -876,11 +914,33 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
return LocalCache.addressables[key]
|
||||
}
|
||||
|
||||
fun findStatusesForUser(
|
||||
suspend fun findStatusesForUser(
|
||||
myUser: User,
|
||||
onResult: (ImmutableList<AddressableNote>) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) { onResult(LocalCache.findStatusesForUser(myUser)) }
|
||||
withContext(Dispatchers.IO) {
|
||||
onResult(LocalCache.findStatusesForUser(myUser))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findOtsEventsForNote(
|
||||
note: Note,
|
||||
onResult: (Long?) -> Unit,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
onResult(LocalCache.findEarliestOtsForNote(note))
|
||||
}
|
||||
}
|
||||
|
||||
fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note)
|
||||
|
||||
suspend fun findModificationEventsForNote(
|
||||
note: Note,
|
||||
onResult: (List<Note>) -> Unit,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
onResult(LocalCache.findLatestModificationForNote(note))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
|
||||
@@ -937,7 +997,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
fun returnNIP19References(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
onNewReferences: (List<Nip19.Return>) -> Unit,
|
||||
onNewReferences: (List<Nip19Bech32.Entity>) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onNewReferences(MarkdownParser().returnNIP19References(content, tags))
|
||||
@@ -954,17 +1014,37 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
}
|
||||
}
|
||||
|
||||
fun parseNIP19(
|
||||
fun loadNEmbedIfNeeded(nembed: Event) {
|
||||
val baseNote = LocalCache.getNoteIfExists(nembed.id)
|
||||
if (baseNote?.event == null) {
|
||||
LocalCache.verifyAndConsume(nembed, null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseNIP19(
|
||||
str: String,
|
||||
onNote: (LoadedBechLink) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
Nip19.uriToRoute(str)?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
Nip19Bech32.uriToRoute(str)?.let {
|
||||
var returningNote: Note? = null
|
||||
if (
|
||||
it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS
|
||||
) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> returningNote = note }
|
||||
|
||||
when (val parsed = it.entity) {
|
||||
is Nip19Bech32.NSec -> {}
|
||||
is Nip19Bech32.NPub -> {}
|
||||
is Nip19Bech32.NProfile -> {}
|
||||
is Nip19Bech32.Note -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note }
|
||||
is Nip19Bech32.NEvent -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note }
|
||||
is Nip19Bech32.NEmbed -> {
|
||||
loadNEmbedIfNeeded(parsed.event)
|
||||
|
||||
LocalCache.checkGetOrCreateNote(parsed.event.id)?.let { note ->
|
||||
returningNote = note
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NRelay -> {}
|
||||
is Nip19Bech32.NAddress -> LocalCache.checkGetOrCreateNote(parsed.atag)?.let { note -> returningNote = note }
|
||||
else -> {}
|
||||
}
|
||||
|
||||
onNote(LoadedBechLink(returningNote, it))
|
||||
@@ -1094,6 +1174,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
||||
"Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}",
|
||||
)
|
||||
invalidateInsertData(newNotes)
|
||||
upgradeAttestations()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1264,7 +1345,7 @@ class HasNotificationDot(bottomNavigationItems: ImmutableList<Route>) {
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
|
||||
@Immutable data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19Bech32.ParseReturn)
|
||||
|
||||
public fun <T, K> allOrNothingSigningOperations(
|
||||
remainingTos: List<T>,
|
||||
|
||||
@@ -101,6 +101,7 @@ import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
@@ -120,7 +121,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
@@ -681,7 +681,7 @@ fun ShowVideoStreaming(
|
||||
) {
|
||||
val zoomableUrlVideo =
|
||||
remember(it) {
|
||||
ZoomableUrlVideo(
|
||||
MediaUrlVideo(
|
||||
url = url,
|
||||
description = title,
|
||||
artworkUri = artworkUri,
|
||||
@@ -710,8 +710,8 @@ fun ShortChannelHeader(
|
||||
nav: (String) -> Unit,
|
||||
showFlag: Boolean,
|
||||
) {
|
||||
val channelState = baseChannel.live.observeAsState()
|
||||
val channel = remember(channelState) { channelState.value?.channel } ?: return
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channel = channelState?.channel ?: return
|
||||
|
||||
val automaticallyShowProfilePicture =
|
||||
remember {
|
||||
@@ -782,8 +782,8 @@ fun LongChannelHeader(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val channelState = baseChannel.live.observeAsState()
|
||||
val channel = remember(channelState) { channelState.value?.channel } ?: return
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channel = channelState?.channel ?: return
|
||||
|
||||
Row(
|
||||
lineModifier,
|
||||
@@ -865,7 +865,7 @@ fun LongChannelHeader(
|
||||
)
|
||||
Spacer(DoubleHorzSpacer)
|
||||
NormalTimeAgo(note, remember { Modifier.weight(1f) })
|
||||
MoreOptionsButton(note, accountViewModel)
|
||||
MoreOptionsButton(note, null, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EditNote
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -106,7 +105,6 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
@@ -115,6 +113,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
@@ -661,11 +660,7 @@ private fun EditRoomSubjectButton(
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
|
||||
onClick = { wantsToPost = true },
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
contentPadding = ZeroPadding,
|
||||
) {
|
||||
Icon(
|
||||
tint = Color.White,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user