mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
369 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ff434a07 | ||
|
|
51432c8c50 | ||
|
|
483e7d673c | ||
|
|
3ce6ce9951 | ||
|
|
84f51dc473 | ||
|
|
5f4886ce52 | ||
|
|
dea6f0ca21 | ||
|
|
01bdc41286 | ||
|
|
8c2e89197e | ||
|
|
712950c2a1 | ||
|
|
95cc2ee589 | ||
|
|
76283463b0 | ||
|
|
5179a821b2 | ||
|
|
974a8011f9 | ||
|
|
771bb3fc27 | ||
|
|
10bd63ca2f | ||
|
|
1fad3d1c8a | ||
|
|
67cc24c4d9 | ||
|
|
4368b34569 | ||
|
|
65540c8a95 | ||
|
|
b801aa6bd1 | ||
|
|
7224ad4b05 | ||
|
|
b043e2da2c | ||
|
|
f8a60203ec | ||
|
|
7f18bc3a88 | ||
|
|
31740d8855 | ||
|
|
8641e0fec5 | ||
|
|
c8f1e13448 | ||
|
|
580cf76577 | ||
|
|
61549ebfd3 | ||
|
|
797d1bb2b4 | ||
|
|
99c16879c1 | ||
|
|
0bf80e88ce | ||
|
|
a71679ef43 | ||
|
|
4bbb34e8c9 | ||
|
|
0e1420a626 | ||
|
|
79782ee8e7 | ||
|
|
81b70780a6 | ||
|
|
277fc5971e | ||
|
|
850bc15f4f | ||
|
|
6d2db65c12 | ||
|
|
89ce6dbab2 | ||
|
|
80bfbf68b4 | ||
|
|
4464247ddb | ||
|
|
486d43dc1b | ||
|
|
6731ca59cd | ||
|
|
7d620cd89a | ||
|
|
82a2586eab | ||
|
|
5a3f912b73 | ||
|
|
bc54134e65 | ||
|
|
0f080219b2 | ||
|
|
ff54629601 | ||
|
|
f889ff2547 | ||
|
|
8f257a7e2d | ||
|
|
9015089593 | ||
|
|
2b87930283 | ||
|
|
15d94ca9c4 | ||
|
|
44af7f958c | ||
|
|
2b2028853e | ||
|
|
66dcc03351 | ||
|
|
22ba150c4e | ||
|
|
63cc00976f | ||
|
|
cc9d4c237a | ||
|
|
446135ea2a | ||
|
|
a38bbd7168 | ||
|
|
635ed1afc1 | ||
|
|
130ad40a3a | ||
|
|
be5e58111e | ||
|
|
8b012884b1 | ||
|
|
4949a5c459 | ||
|
|
ab330fea96 | ||
|
|
8209c45303 | ||
|
|
206935289e | ||
|
|
571ff0e72b | ||
|
|
940914adf5 | ||
|
|
1496f012a5 | ||
|
|
2a552bea4d | ||
|
|
d1dc06a467 | ||
|
|
f173f3194b | ||
|
|
8ca63f32f4 | ||
|
|
6b3e10ed28 | ||
|
|
1ae785061e | ||
|
|
ddba4ac510 | ||
|
|
0fe6bd6c83 | ||
|
|
925289ae64 | ||
|
|
f6f82a16f5 | ||
|
|
54511dcc69 | ||
|
|
2f457d135f | ||
|
|
362082d608 | ||
|
|
78cfa98456 | ||
|
|
5e37a97d1e | ||
|
|
561189144e | ||
|
|
285a61e38f | ||
|
|
2ca877b3c3 | ||
|
|
f105188738 | ||
|
|
0a11de1892 | ||
|
|
1596a7b559 | ||
|
|
d4a3ee2651 | ||
|
|
c60ad472e8 | ||
|
|
04e4c4692f | ||
|
|
ab747f5e93 | ||
|
|
e370e75ba4 | ||
|
|
a669e37774 | ||
|
|
86fe9b4a65 | ||
|
|
c6a0b0950a | ||
|
|
f03cd04566 | ||
|
|
3175d40482 | ||
|
|
24d722cdb7 | ||
|
|
14bc1fb7d4 | ||
|
|
90955515d0 | ||
|
|
a22ec35a26 | ||
|
|
f38f5737b0 | ||
|
|
e958515dad | ||
|
|
add6b06446 | ||
|
|
d070712f2b | ||
|
|
b9f58b3701 | ||
|
|
91f3f036ae | ||
|
|
4ef90e26cf | ||
|
|
f7182d6393 | ||
|
|
99861facc9 | ||
|
|
a5728daeb0 | ||
|
|
a4ac2eeb89 | ||
|
|
a5a320b889 | ||
|
|
8f927c29e3 | ||
|
|
a739b27d14 | ||
|
|
2f2ca97ec1 | ||
|
|
114186ce06 | ||
|
|
8187a7f5a9 | ||
|
|
f8319c720a | ||
|
|
3d93fe3d18 | ||
|
|
b4373ae1ed | ||
|
|
a37effd42d | ||
|
|
655cd20a01 | ||
|
|
a13dbf2bda | ||
|
|
f1affc2dbb | ||
|
|
cbc0f95498 | ||
|
|
b3f8b06fea | ||
|
|
ed4c19ac79 | ||
|
|
37bd7e34a0 | ||
|
|
c1d05f8b2f | ||
|
|
9c715dd381 | ||
|
|
a720a636d3 | ||
|
|
5322d6e900 | ||
|
|
9d849f12c0 | ||
|
|
18dc5354bd | ||
|
|
a013f1f1cc | ||
|
|
e1d236d9cf | ||
|
|
12a2a21c0e | ||
|
|
9ae13a9f97 | ||
|
|
bd64a7de63 | ||
|
|
12a3cd5113 | ||
|
|
1d23e87bd9 | ||
|
|
b23bea8971 | ||
|
|
830ade9ad6 | ||
|
|
ed07248f7d | ||
|
|
9aa5993748 | ||
|
|
e7909beaab | ||
|
|
79e3450a46 | ||
|
|
783204b57f | ||
|
|
1adb2b2caa | ||
|
|
3f579793ca | ||
|
|
8040608e14 | ||
|
|
2d202e2244 | ||
|
|
2e66d278ad | ||
|
|
1bfbf3643d | ||
|
|
5990bf0213 | ||
|
|
5e3db99675 | ||
|
|
a291741c37 | ||
|
|
94ee80d8ea | ||
|
|
f7de66c6a5 | ||
|
|
48d890b354 | ||
|
|
c7a9bd1226 | ||
|
|
c40c7bc62b | ||
|
|
cca7a10322 | ||
|
|
fe6ef1df0a | ||
|
|
3e27567532 | ||
|
|
0a15e079dc | ||
|
|
48f4fc4a0f | ||
|
|
d9396ce453 | ||
|
|
817c5172b6 | ||
|
|
cfdb20892e | ||
|
|
75922f93bc | ||
|
|
42801e3eca | ||
|
|
64321dbe7c | ||
|
|
b11b52b60c | ||
|
|
00e5470642 | ||
|
|
7ee3b8a196 | ||
|
|
585bb57f64 | ||
|
|
6981fe8f8a | ||
|
|
abdad7fbea | ||
|
|
57b35398be | ||
|
|
47398e6e86 | ||
|
|
e775ae9ada | ||
|
|
6a9a321e2b | ||
|
|
0aee4407db | ||
|
|
a816fa561a | ||
|
|
dc745c3cdb | ||
|
|
0b30c83143 | ||
|
|
dc8804933d | ||
|
|
b28f0bff74 | ||
|
|
e96e3c0760 | ||
|
|
b7ea167420 | ||
|
|
744a55f497 | ||
|
|
b55f999efb | ||
|
|
4e09d9e54a | ||
|
|
5afa333fab | ||
|
|
c0946f5c0e | ||
|
|
3ece4fcf8b | ||
|
|
b1193c5d11 | ||
|
|
12b0afd9c6 | ||
|
|
df45d68b74 | ||
|
|
11bf702e02 | ||
|
|
47e2683aa3 | ||
|
|
e4fc00669e | ||
|
|
f64db1271b | ||
|
|
35647a8361 | ||
|
|
a2c8658972 | ||
|
|
38e18cf0a7 | ||
|
|
78c9974036 | ||
|
|
7a60a6804a | ||
|
|
5e6e2835ca | ||
|
|
180083147d | ||
|
|
50b0b5af37 | ||
|
|
9d27b8d47e | ||
|
|
fbb4dc5e59 | ||
|
|
a2296f2365 | ||
|
|
58ff2e6157 | ||
|
|
ff490a2b14 | ||
|
|
067d694036 | ||
|
|
401d807d29 | ||
|
|
eab202f721 | ||
|
|
f726c656b5 | ||
|
|
37dab1c8b6 | ||
|
|
9a5d8522fb | ||
|
|
5abbd07722 | ||
|
|
13579f9473 | ||
|
|
cea9cdcd34 | ||
|
|
ed22155940 | ||
|
|
43497f2ce0 | ||
|
|
80e58f72f8 | ||
|
|
234751a4b3 | ||
|
|
ef567110d0 | ||
|
|
42b89f438b | ||
|
|
b01df2b38e | ||
|
|
b89efb547e | ||
|
|
e6554a5b4a | ||
|
|
c58b13e2f7 | ||
|
|
241372ce61 | ||
|
|
49ed594f53 | ||
|
|
03b76357ff | ||
|
|
97490bb6ef | ||
|
|
dbf5267c5c | ||
|
|
e1ce638d7f | ||
|
|
88b80a0da1 | ||
|
|
d6b571590a | ||
|
|
83f3af340f | ||
|
|
e9cf9b21a8 | ||
|
|
b1645590fe | ||
|
|
a5da4ea53d | ||
|
|
3a082d131f | ||
|
|
28d501ed02 | ||
|
|
a04fd4a9f2 | ||
|
|
2424dc3961 | ||
|
|
58f1515387 | ||
|
|
9ee3da3910 | ||
|
|
cc44b4dd55 | ||
|
|
b8e4ccfc28 | ||
|
|
0fc6757d4a | ||
|
|
ee94cc6bf6 | ||
|
|
f224fe6b40 | ||
|
|
403a1723de | ||
|
|
6cf1960df4 | ||
|
|
8676752f19 | ||
|
|
7012d949c2 | ||
|
|
8344274011 | ||
|
|
2bda50ad4a | ||
|
|
b0953310c2 | ||
|
|
65910295db | ||
|
|
7a957bd6ec | ||
|
|
292ce779a1 | ||
|
|
924b21cdfc | ||
|
|
7b539e63bd | ||
|
|
d37fef94fe | ||
|
|
93a1853317 | ||
|
|
9944f98bde | ||
|
|
05138232f4 | ||
|
|
83d7085375 | ||
|
|
a8a3915c8d | ||
|
|
83bcf53583 | ||
|
|
875d9544ec | ||
|
|
7ce2a0bbea | ||
|
|
a3b9ea6a42 | ||
|
|
ca2c811859 | ||
|
|
b09bb0c3c5 | ||
|
|
a68ea1abe5 | ||
|
|
02a9aaab60 | ||
|
|
efd2ddba71 | ||
|
|
e10c5771d8 | ||
|
|
b685b38565 | ||
|
|
15822fee8e | ||
|
|
e96e218025 | ||
|
|
a11263ce9e | ||
|
|
4417201582 | ||
|
|
6a85ab743d | ||
|
|
4434b0e0e5 | ||
|
|
7c8381624b | ||
|
|
e470531210 | ||
|
|
182cf62a3b | ||
|
|
3e666df896 | ||
|
|
20aca144fa | ||
|
|
25d9f7b529 | ||
|
|
306a7b3041 | ||
|
|
f2311f294e | ||
|
|
b51f551b4b | ||
|
|
af751d56ce | ||
|
|
f4d66d9cbf | ||
|
|
37d839504c | ||
|
|
e8e696dcea | ||
|
|
4db0faeda4 | ||
|
|
b8ca1a15fb | ||
|
|
407ee74a55 | ||
|
|
67bcc52b25 | ||
|
|
9afc94c050 | ||
|
|
9d9283e05f | ||
|
|
5692625922 | ||
|
|
7e07573fa5 | ||
|
|
1f08f33600 | ||
|
|
1ee3dc0ff3 | ||
|
|
3626f24b5f | ||
|
|
24dc4ba7a5 | ||
|
|
a01a28de0e | ||
|
|
9a6f88b81b | ||
|
|
32067fe800 | ||
|
|
573cfa8b75 | ||
|
|
b65139f520 | ||
|
|
62ff9ac94b | ||
|
|
584c2860e4 | ||
|
|
12a1c3fe6d | ||
|
|
5b1e7c3451 | ||
|
|
9b9cc092dc | ||
|
|
712c8ab2dd | ||
|
|
dbf0256b1c | ||
|
|
171a7841b3 | ||
|
|
611dcc0189 | ||
|
|
ef31f56eab | ||
|
|
771cdd6ebe | ||
|
|
d1f61c0bba | ||
|
|
5d04f3ea99 | ||
|
|
bd37e4a9df | ||
|
|
3cdfdfa8e8 | ||
|
|
8171c1ee5e | ||
|
|
0366f6235c | ||
|
|
7890ac9db5 | ||
|
|
7a53708ccc | ||
|
|
551ed64e98 | ||
|
|
f0b4660719 | ||
|
|
29c4c42547 | ||
|
|
107e09aefb | ||
|
|
63ad7fd205 | ||
|
|
c31b99b1dc | ||
|
|
851ec71f4a | ||
|
|
e30b4f7c7e | ||
|
|
00f9f7ba52 | ||
|
|
564f926213 | ||
|
|
0cf891bb4d | ||
|
|
fba873dab9 | ||
|
|
322853f696 | ||
|
|
19c3421e92 | ||
|
|
206d6c68bb |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -14,10 +14,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
|
||||
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
@@ -12,10 +12,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Amethyst Privacy Policy & Terms of Use
|
||||
|
||||
Effective as of Jan 20, 2023.
|
||||
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
|
||||
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
|
||||
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
|
||||
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
|
||||
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -4,6 +4,15 @@
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
|
||||
[<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/)
|
||||
[<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)
|
||||
|
||||
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
|
||||
@@ -23,7 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
@@ -32,13 +41,25 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtags
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Verifiable static content in URLs (NIP-94)
|
||||
- [x] Login with QR
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Accessible uploads
|
||||
- [x] Bounty support (nostrbounties.com)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] External Identity Support (NIP-39)
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Sensitive Content (NIP-36)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Relay Authentication (NIP-42)
|
||||
- [ ] Generic Tags (NIP-12)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
@@ -51,6 +72,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [ ] Account Creation / Backup Guidance (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
|
||||
|
||||
# Development Overview
|
||||
|
||||
## Overall Architecture
|
||||
@@ -75,7 +97,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
|
||||
## Setup
|
||||
|
||||
Make sure to have the following pre-requisites installed:
|
||||
1. Java 11
|
||||
1. Java 17
|
||||
2. Android Studio
|
||||
3. Android 8.0+ Phone or Emulation setup
|
||||
|
||||
@@ -105,8 +127,15 @@ Build the app:
|
||||
```
|
||||
|
||||
## Installing on device
|
||||
|
||||
For the F-Droid build:
|
||||
```bash
|
||||
./gradlew installDebug
|
||||
./gradlew installFdroidDebug
|
||||
```
|
||||
|
||||
For the Play build:
|
||||
```bash
|
||||
./gradlew installPlayDebug
|
||||
```
|
||||
|
||||
## How to Deploy
|
||||
@@ -150,6 +179,12 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
|
||||
</a>
|
||||
|
||||
# MIT License
|
||||
|
||||
Copyright (c) 2023 Vitor Pamplona
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 112
|
||||
versionName "0.31.2"
|
||||
versionCode 144
|
||||
versionName "0.42.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -57,12 +57,12 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '11'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -78,11 +78,15 @@ android {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -104,13 +108,13 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
implementation "net.engawapg.lib:zoomable:1.4.0"
|
||||
implementation 'net.engawapg.lib:zoomable:1.4.2'
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.8.0'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
@@ -123,17 +127,17 @@ dependencies {
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// link preview
|
||||
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.5'
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
@@ -142,15 +146,14 @@ dependencies {
|
||||
// view svgs
|
||||
implementation "io.coil-kt:coil-svg:$coil_version"
|
||||
|
||||
// create blurhash
|
||||
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||
|
||||
// Rendering clickable text
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
// tabs for user profiles
|
||||
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
|
||||
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
|
||||
|
||||
// Parses URLs from Text:
|
||||
implementation "io.github.url-detector:url-detector:0.1.23"
|
||||
|
||||
@@ -173,8 +176,8 @@ dependencies {
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "io.mockk:mockk:1.13.4"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ImageUploadTesting {
|
||||
|
||||
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
"image/gif",
|
||||
ImgurServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
"image/gif",
|
||||
NostrBuildServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
"image/gif",
|
||||
NostrImgServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent.Companion.createEncryptionPrivateKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivateZapTests {
|
||||
|
||||
@Test
|
||||
fun testPollZap() {
|
||||
val poll = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "New poll \n\n #zappoll",
|
||||
"created_at": 1682440713,
|
||||
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
|
||||
"kind": 6969,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
|
||||
"tags": [
|
||||
[
|
||||
"poll_option",
|
||||
"0",
|
||||
"Test 1"
|
||||
],
|
||||
[
|
||||
"poll_option",
|
||||
"1",
|
||||
"Test 2"
|
||||
],
|
||||
[
|
||||
"value_maximum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"value_minimum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"consensus_threshold",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"closed_at",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
poll,
|
||||
setOf("wss://relay.damus.io/"),
|
||||
loggedIn,
|
||||
0,
|
||||
"",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKind1PrivateZap() {
|
||||
val textNote = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
"created_at": 1682369982,
|
||||
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
|
||||
"kind": 1,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
|
||||
"tags": [
|
||||
[
|
||||
"p",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
textNote,
|
||||
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
|
||||
loggedIn,
|
||||
null,
|
||||
"test",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun TranslateableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
@@ -2,10 +2,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<!-- Used for SDK < 29 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -16,7 +20,7 @@
|
||||
android:name=".Amethyst"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/amethyst"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:supportsRtl="true"
|
||||
@@ -43,11 +47,18 @@
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrwalletconnect" />
|
||||
<data android:scheme="nostr+walletconnect" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -6,11 +6,15 @@ import android.content.SharedPreferences
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47URI
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Persona
|
||||
@@ -27,10 +31,7 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean,
|
||||
val current: Boolean,
|
||||
val displayName: String?,
|
||||
val profilePicture: String?
|
||||
val hasPrivKey: Boolean = false
|
||||
)
|
||||
|
||||
private object PrefKeys {
|
||||
@@ -38,8 +39,6 @@ private object PrefKeys {
|
||||
const val SAVED_ACCOUNTS = "all_saved_accounts"
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val DISPLAY_NAME = "display_name"
|
||||
const val PROFILE_PICTURE_URL = "profile_picture"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
@@ -47,6 +46,10 @@ private object PrefKeys {
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val DEFAULT_ZAPTYPE = "defaultZapType"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
@@ -59,53 +62,73 @@ private val gson = GsonBuilder().create()
|
||||
object LocalPreferences {
|
||||
private const val comma = ","
|
||||
|
||||
private var currentAccount: String?
|
||||
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
set(npub) {
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
private var _currentAccount: String? = null
|
||||
|
||||
private fun currentAccount(): String? {
|
||||
if (_currentAccount == null) {
|
||||
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
return _currentAccount
|
||||
}
|
||||
|
||||
private fun updateCurrentAccount(npub: String) {
|
||||
if (_currentAccount != npub) {
|
||||
_currentAccount = npub
|
||||
|
||||
encryptedPreferences().edit().apply {
|
||||
putString(PrefKeys.CURRENT_ACCOUNT, npub)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private val savedAccounts: List<String>
|
||||
get() = encryptedPreferences()
|
||||
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
private var _savedAccounts: List<String>? = null
|
||||
|
||||
private fun savedAccounts(): List<String> {
|
||||
if (_savedAccounts == null) {
|
||||
_savedAccounts = encryptedPreferences()
|
||||
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
}
|
||||
return _savedAccounts!!
|
||||
}
|
||||
|
||||
private fun updateSavedAccounts(accounts: List<String>) {
|
||||
if (_savedAccounts != accounts) {
|
||||
_savedAccounts = accounts
|
||||
|
||||
encryptedPreferences().edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private val prefsDirPath: String
|
||||
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
|
||||
|
||||
private fun addAccount(npub: String) {
|
||||
val accounts = savedAccounts.toMutableList()
|
||||
val accounts = savedAccounts().toMutableList()
|
||||
if (npub !in accounts) {
|
||||
accounts.add(npub)
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
}
|
||||
|
||||
private fun setCurrentAccount(account: Account) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
currentAccount = npub
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(npub)
|
||||
}
|
||||
|
||||
fun switchToAccount(npub: String) {
|
||||
currentAccount = npub
|
||||
updateCurrentAccount(npub)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the account from the app level shared preferences
|
||||
*/
|
||||
private fun removeAccount(npub: String) {
|
||||
val accounts = savedAccounts.toMutableList()
|
||||
val accounts = savedAccounts().toMutableList()
|
||||
if (accounts.remove(npub)) {
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,11 +168,11 @@ object LocalPreferences {
|
||||
removeAccount(npub)
|
||||
deleteUserPreferenceFile(npub)
|
||||
|
||||
if (savedAccounts.isEmpty()) {
|
||||
if (savedAccounts().isEmpty()) {
|
||||
val appPrefs = encryptedPreferences()
|
||||
appPrefs.edit().clear().apply()
|
||||
} else if (currentAccount == npub) {
|
||||
currentAccount = savedAccounts.elementAt(0)
|
||||
} else if (currentAccount() == npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,17 +182,8 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> {
|
||||
return savedAccounts.map { npub ->
|
||||
val prefs = encryptedPreferences(npub)
|
||||
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
|
||||
|
||||
AccountInfo(
|
||||
npub = npub,
|
||||
hasPrivKey = hasPrivKey,
|
||||
current = npub == currentAccount,
|
||||
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
|
||||
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
|
||||
)
|
||||
return savedAccounts().map { npub ->
|
||||
AccountInfo(npub = npub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,17 +199,19 @@ object LocalPreferences {
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
|
||||
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences(currentAccount).apply {
|
||||
encryptedPreferences(currentAccount()).apply {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
@@ -207,12 +223,24 @@ object LocalPreferences {
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val defaultZapType = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
|
||||
object : TypeToken<LnZapEvent.ZapType>() {}.type
|
||||
) ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
|
||||
object : TypeToken<ServersAvailable>() {}.type
|
||||
) ?: ServersAvailable.IMGUR
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
gson.fromJson(it, Nip47URI::class.java)
|
||||
@@ -247,7 +275,7 @@ object LocalPreferences {
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
|
||||
return Account(
|
||||
val a = Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
@@ -256,28 +284,34 @@ object LocalPreferences {
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
defaultZapType,
|
||||
defaultFileServer,
|
||||
defaultHomeFollowList,
|
||||
defaultStoriesFollowList,
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
latestContactList
|
||||
)
|
||||
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences(currentAccount).edit().apply {
|
||||
encryptedPreferences(currentAccount()).edit().apply {
|
||||
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences(currentAccount).run {
|
||||
encryptedPreferences(currentAccount()).run {
|
||||
return getLong(PrefKeys.LAST_READ(route), 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateSingleUserPrefs() {
|
||||
if (currentAccount != null) return
|
||||
if (currentAccount() != null) return
|
||||
|
||||
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
|
||||
val npub = Hex.decode(pubkey).toNpub()
|
||||
@@ -314,6 +348,6 @@ object LocalPreferences {
|
||||
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
addAccount(npub)
|
||||
currentAccount = npub
|
||||
updateCurrentAccount(npub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
|
||||
@@ -33,10 +34,11 @@ object ServiceManager {
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
@@ -61,6 +63,7 @@ object ServiceManager {
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrVideoDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ object VideoCache {
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
fun get(context: Context): CacheDataSource.Factory {
|
||||
fun init(context: Context) {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
@@ -35,7 +35,9 @@ object VideoCache {
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): CacheDataSource.Factory {
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
@@ -14,7 +15,7 @@ class AntiSpamFilter {
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event): Boolean {
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
val idHex = event.id
|
||||
|
||||
// if short message, ok
|
||||
@@ -27,7 +28,7 @@ class AntiSpamFilter {
|
||||
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
|
||||
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
// Log down offenders
|
||||
if (spamMessages.get(hash) == null) {
|
||||
|
||||
@@ -6,15 +6,16 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
|
||||
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,14 @@ fun HexKey.toDisplayHexKey(): String {
|
||||
}
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.toByteArray()
|
||||
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (key.startsWith("npub")) {
|
||||
key.bechToBytes()
|
||||
} else if (key.startsWith("note")) {
|
||||
key.bechToBytes()
|
||||
} else { // if (pattern.matcher(key).matches()) {
|
||||
// } else {
|
||||
} else if (pubKeyParsed != null) {
|
||||
pubKeyParsed
|
||||
} else {
|
||||
Hex.decode(key)
|
||||
}
|
||||
}
|
||||
@@ -84,15 +84,15 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkeyRelay, restOfWord)
|
||||
} else if (key.startsWith("nevent", true)) {
|
||||
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
|
||||
} else if (key.startsWith("nevent1", true)) {
|
||||
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteRelayId, restOfWord)
|
||||
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
|
||||
} else if (key.startsWith("naddr1", true)) {
|
||||
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins
|
||||
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object LocalCache {
|
||||
val metadataParser = jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.readerFor(UserMetadata::class.java)
|
||||
val metadataParser by lazy {
|
||||
jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.readerFor(UserMetadata::class.java)
|
||||
}
|
||||
|
||||
val antiSpam = AntiSpamFilter()
|
||||
|
||||
val users = ConcurrentHashMap<HexKey, User>()
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
val users = ConcurrentHashMap<HexKey, User>(5000)
|
||||
val notes = ConcurrentHashMap<HexKey, Note>(5000)
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
|
||||
|
||||
val awaitingPaymentRequests =
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
if (isValidHexNpub(key)) {
|
||||
@@ -170,6 +159,20 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PeopleListEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
|
||||
@@ -187,7 +190,7 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
@@ -208,7 +211,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LongTextNoteEvent, relay: Relay?) {
|
||||
@@ -223,7 +226,7 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
@@ -237,10 +240,46 @@ object LocalCache {
|
||||
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PollNoteEvent, 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 replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -251,7 +290,7 @@ object LocalCache {
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList<Note>())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,8 +308,6 @@ object LocalCache {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
author.updateAcceptedBadges(note)
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +329,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@@ -302,12 +339,10 @@ object LocalCache {
|
||||
|
||||
fun consume(event: ContactListEvent) {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
val follows = event.unverifiedFollowKeySet()
|
||||
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) {
|
||||
// Saves relay list only if it's a user that is currently been seen
|
||||
// avoids processing empty contact lists.
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
|
||||
user.updateContactList(event)
|
||||
|
||||
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
|
||||
}
|
||||
}
|
||||
@@ -338,7 +373,7 @@ object LocalCache {
|
||||
recipient.addMessage(author, note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: DeletionEvent) {
|
||||
@@ -363,6 +398,7 @@ object LocalCache {
|
||||
masterNote.removeBoost(deleteNote)
|
||||
masterNote.removeReaction(deleteNote)
|
||||
masterNote.removeZap(deleteNote)
|
||||
masterNote.removeZapPayment(deleteNote)
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
@@ -386,7 +422,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
if (deletedAtLeastOne) {
|
||||
live.invalidateData()
|
||||
// refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,7 +448,7 @@ object LocalCache {
|
||||
it.addBoost(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ReactionEvent) {
|
||||
@@ -450,6 +486,8 @@ object LocalCache {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ReportEvent, relay: Relay?) {
|
||||
@@ -480,6 +518,8 @@ object LocalCache {
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
@@ -496,7 +536,7 @@ object LocalCache {
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +556,7 @@ object LocalCache {
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -543,16 +583,16 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = event.replyTos()
|
||||
val replyTo = event.tagsWithoutCitations()
|
||||
.filter { it != event.channel() }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
.filter { it.event !is ChannelCreateEvent }
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
@@ -563,7 +603,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@@ -576,19 +616,17 @@ object LocalCache {
|
||||
|
||||
fun consume(event: LnZapEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
|
||||
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
|
||||
(
|
||||
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()
|
||||
?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
|
||||
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
|
||||
)
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
@@ -606,6 +644,8 @@ object LocalCache {
|
||||
mentions.forEach {
|
||||
it.addZap(zapRequest, note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapRequestEvent) {
|
||||
@@ -629,6 +669,138 @@ object LocalCache {
|
||||
mentions.forEach {
|
||||
it.addZap(note, null)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileHeaderEvent, 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: FileStorageHeaderEvent, 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: HighlightEvent, 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())
|
||||
|
||||
// Adds to user profile
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileStorageEvent, 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
|
||||
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val stream = FileOutputStream(File(cachePath, event.id))
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
}
|
||||
|
||||
// this is an invalid event. But we don't need to keep the data in memory.
|
||||
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
|
||||
|
||||
note.loadEvent(eventNoData, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent) {
|
||||
// Does nothing without a response callback.
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
zappedNote?.addZapPayment(note, null)
|
||||
|
||||
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentResponseEvent) {
|
||||
val requestId = event.requestId()
|
||||
val pair = awaitingPaymentRequests[requestId] ?: return
|
||||
|
||||
val (zappedNote, responseCallback) = pair
|
||||
|
||||
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
requestNote?.let { request ->
|
||||
zappedNote?.addZapPayment(request, note)
|
||||
}
|
||||
|
||||
if (responseCallback != null) {
|
||||
responseCallback(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
@@ -642,6 +814,7 @@ object LocalCache {
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
return notes.values.filter {
|
||||
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
|
||||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
|
||||
(it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) ||
|
||||
it.idHex.startsWith(text, true) ||
|
||||
it.idNote().startsWith(text, true)
|
||||
@@ -679,11 +852,6 @@ object LocalCache {
|
||||
notes.remove(it.idHex)
|
||||
// Doesn't need to clean up the replies and mentions.. Too small to matter.
|
||||
|
||||
// reverts the add
|
||||
val mentions =
|
||||
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
||||
?.mapNotNull { checkGetOrCreateUser(it) }
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { _ ->
|
||||
it.removeReply(it)
|
||||
@@ -734,30 +902,23 @@ object LocalCache {
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData(this)
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData()
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.invalidateData()
|
||||
private fun refreshObservers(newNote: Note) {
|
||||
live.invalidateData(newNote)
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheLiveData(val cache: LocalCache) :
|
||||
LiveData<LocalCacheState>(LocalCacheState(cache)) {
|
||||
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
if (hasActiveObservers()) {
|
||||
postValue(bundledNewNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(LocalCacheState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheState(val cache: LocalCache)
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
@@ -20,6 +22,7 @@ val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
|
||||
@@ -43,6 +46,8 @@ open class Note(val idHex: String) {
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
private set
|
||||
@@ -51,15 +56,21 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
open fun idNote() = id().toNote()
|
||||
|
||||
open fun toNEvent(): String {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channel(): Channel? {
|
||||
val channelHex =
|
||||
(event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.let { it.id }
|
||||
fun channelHex(): HexKey? {
|
||||
return (event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
}
|
||||
|
||||
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
fun channel(): Channel? {
|
||||
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
@@ -148,6 +159,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
@@ -155,6 +177,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
@@ -165,6 +188,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
@@ -191,17 +225,27 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(user: User): Boolean {
|
||||
fun isZappedBy(user: User, account: Account): Boolean {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any { it.key.author == user }
|
||||
return zaps.any {
|
||||
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
} || zapPayments.any {
|
||||
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
|
||||
val response = if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
return reactions.any { it.author == user }
|
||||
return reactions.any { it.author === user }
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
return boosts.any { it.author == user }
|
||||
return boosts.any { it.author === user }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
@@ -222,12 +266,44 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
.filter { it.amount != null }
|
||||
.associate {
|
||||
it.lnInvoice() to it.amount
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val completedPayments = if (privKey != null && walletServicePubkey != null) {
|
||||
// Payments confirmed by the User's Wallet
|
||||
zapPayments
|
||||
.asSequence()
|
||||
.filter {
|
||||
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
|
||||
response is PayInvoiceSuccessResponse
|
||||
}
|
||||
.associate {
|
||||
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
|
||||
val amount = try {
|
||||
if (lnInvoice == null) {
|
||||
null
|
||||
} else {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
null
|
||||
}
|
||||
lnInvoice to amount
|
||||
}
|
||||
.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
|
||||
}
|
||||
|
||||
fun hasPledgeBy(user: User): Boolean {
|
||||
@@ -269,45 +345,6 @@ open class Note(val idHex: String) {
|
||||
)
|
||||
}
|
||||
|
||||
fun directlyCiteUsersHex(): Set<HexKey> {
|
||||
val matcher = tagSearch.matcher(event?.content() ?: "")
|
||||
val returningList = mutableSetOf<String>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun directlyCiteUsers(): Set<User> {
|
||||
val matcher = tagSearch.matcher(event?.content() ?: "")
|
||||
val returningList = mutableSetOf<User>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
|
||||
if (tag != null && tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
returningList.add(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun directlyCites(userProfile: User): Boolean {
|
||||
return author == userProfile ||
|
||||
(userProfile in directlyCiteUsers()) ||
|
||||
(event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
|
||||
(event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|
||||
}
|
||||
|
||||
fun isNewThread(): Boolean {
|
||||
return event is RepostEvent || replyTo == null || replyTo?.size == 0
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
@@ -273,7 +274,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun transientFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun cachedFollowingKeySet(): Set<HexKey> {
|
||||
@@ -289,13 +290,13 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun cachedFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this == it.author }
|
||||
return messagesToUser.roomMessages.any { this === it.author }
|
||||
}
|
||||
|
||||
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
||||
@@ -387,6 +388,10 @@ class UserMetadata {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.any { it.startsWith(prefix, true) }
|
||||
}
|
||||
|
||||
fun lnAddress(): String? {
|
||||
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.withSign
|
||||
|
||||
object BlurHashDecoder {
|
||||
|
||||
// cache Math.cos() calculations to improve performance.
|
||||
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
|
||||
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
|
||||
private val cacheCosinesX = HashMap<Int, DoubleArray>()
|
||||
private val cacheCosinesY = HashMap<Int, DoubleArray>()
|
||||
|
||||
/**
|
||||
* Clear calculations stored in memory cache.
|
||||
* The cache is not big, but will increase when many image sizes are used,
|
||||
* if the app needs memory it is recommended to clear it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
cacheCosinesX.clear()
|
||||
cacheCosinesY.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns width/height
|
||||
*/
|
||||
fun aspectRatio(blurHash: String?): Float? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return numCompX.toFloat() / numCompY.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a blur hash into a new bitmap.
|
||||
*
|
||||
* @param useCache use in memory cache for the calculated math, reused by images with same size.
|
||||
* if the cache does not exist yet it will be created and populated with new calculations.
|
||||
* By default it is true.
|
||||
*/
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
val maxAcEnc = decode83(blurHash, 1, 2)
|
||||
val maxAc = (maxAcEnc + 1) / 166f
|
||||
val colors = Array(numCompX * numCompY) { i ->
|
||||
if (i == 0) {
|
||||
val colorEnc = decode83(blurHash, 2, 6)
|
||||
decodeDc(colorEnc)
|
||||
} else {
|
||||
val from = 4 + i * 2
|
||||
val colorEnc = decode83(blurHash, from, from + 2)
|
||||
decodeAc(colorEnc, maxAc * punch)
|
||||
}
|
||||
}
|
||||
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
|
||||
}
|
||||
|
||||
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
|
||||
var result = 0
|
||||
for (i in from until to) {
|
||||
val index = charMap[str[i]] ?: -1
|
||||
if (index != -1) {
|
||||
result = result * 83 + index
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||
val r = colorEnc shr 16
|
||||
val g = (colorEnc shr 8) and 255
|
||||
val b = colorEnc and 255
|
||||
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||
}
|
||||
|
||||
private fun srgbToLinear(colorEnc: Int): Float {
|
||||
val v = colorEnc / 255f
|
||||
return if (v <= 0.04045f) {
|
||||
(v / 12.92f)
|
||||
} else {
|
||||
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||
val r = value / (19 * 19)
|
||||
val g = (value / 19) % 19
|
||||
val b = value % 19
|
||||
return floatArrayOf(
|
||||
signedPow2((r - 9) / 9.0f) * maxAc,
|
||||
signedPow2((g - 9) / 9.0f) * maxAc,
|
||||
signedPow2((b - 9) / 9.0f) * maxAc
|
||||
)
|
||||
}
|
||||
|
||||
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
|
||||
|
||||
private fun composeBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
numCompX: Int,
|
||||
numCompY: Int,
|
||||
colors: Array<FloatArray>,
|
||||
useCache: Boolean
|
||||
): Bitmap {
|
||||
// use an array for better performance when writing pixel colors
|
||||
val imageArray = IntArray(width * height)
|
||||
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
|
||||
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
|
||||
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
for (j in 0 until numCompY) {
|
||||
for (i in 0 until numCompX) {
|
||||
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
|
||||
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
|
||||
val basis = (cosX * cosY).toFloat()
|
||||
val color = colors[j * numCompX + i]
|
||||
r += color[0] * basis
|
||||
g += color[1] * basis
|
||||
b += color[2] * basis
|
||||
}
|
||||
}
|
||||
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(height * numCompY).also {
|
||||
cacheCosinesY[height * numCompY] = it
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
cacheCosinesY[height * numCompY]!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(width * numCompX).also {
|
||||
cacheCosinesX[width * numCompX] = it
|
||||
}
|
||||
}
|
||||
else -> cacheCosinesX[width * numCompX]!!
|
||||
}
|
||||
|
||||
private fun DoubleArray.getCos(
|
||||
calculate: Boolean,
|
||||
x: Int,
|
||||
numComp: Int,
|
||||
y: Int,
|
||||
size: Int
|
||||
): Double {
|
||||
if (calculate) {
|
||||
this[x + numComp * y] = cos(Math.PI * y * x / size)
|
||||
}
|
||||
return this[x + numComp * y]
|
||||
}
|
||||
|
||||
private fun linearToSrgb(value: Float): Int {
|
||||
val v = value.coerceIn(0f, 1f)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private val charMap = listOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
|
||||
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
|
||||
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
|
||||
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
|
||||
)
|
||||
.mapIndexed { i, c -> c to i }
|
||||
.toMap()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||
|
||||
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
|
||||
|
||||
val preferredWidth = 100
|
||||
|
||||
val bitmap = BlurHashDecoder.decode(
|
||||
hash,
|
||||
preferredWidth,
|
||||
(preferredWidth * (1 / aspectRatio)).roundToInt()
|
||||
)
|
||||
|
||||
if (bitmap == null) {
|
||||
throw Exception("Unable to convert Bluehash $hash")
|
||||
}
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return BlurHashFetcher(options, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object BlurHashRequester {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("bluehash:$encodedMessage")
|
||||
.fetcherFactory(BlurHashFetcher.Factory)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
val url: String,
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val description: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
|
||||
val opt = BitmapFactory.Options()
|
||||
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
|
||||
|
||||
val intArray = IntArray(mBitmap.width * mBitmap.height)
|
||||
mBitmap.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
mBitmap.width,
|
||||
0,
|
||||
0,
|
||||
mBitmap.width,
|
||||
mBitmap.height
|
||||
)
|
||||
|
||||
val dim = "${mBitmap.width}x${mBitmap.height}"
|
||||
|
||||
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
|
||||
} else if (aspectRatio < 1) {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
|
||||
} else {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ object Nip47 {
|
||||
|
||||
val url = Uri.parse(uri)
|
||||
|
||||
if (url.scheme != "nostrwalletconnect") {
|
||||
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
|
||||
throw IllegalArgumentException("Not a Wallet Connect QR Code")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
@@ -12,9 +13,11 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
@@ -24,7 +27,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -35,7 +38,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -46,7 +49,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -57,18 +60,18 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountBookmarkListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -78,14 +81,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapPaymentResponseEvent.kind,
|
||||
ChannelMessageEvent.kind,
|
||||
BadgeAwardEvent.kind
|
||||
),
|
||||
@@ -110,4 +115,19 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createAccountBookmarkListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
val event = account.createAuthEvent(relay, challenge)
|
||||
|
||||
if (event != null) {
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -43,7 +44,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList(),
|
||||
@@ -54,7 +55,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
@@ -34,6 +35,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.Error
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
@@ -74,14 +76,21 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
|
||||
is FileHeaderEvent -> LocalCache.consume(event, relay)
|
||||
is FileStorageEvent -> LocalCache.consume(event, relay)
|
||||
is FileStorageHeaderEvent -> LocalCache.consume(event, relay)
|
||||
is HighlightEvent -> LocalCache.consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> LocalCache.consume(event)
|
||||
is LnZapPaymentRequestEvent -> LocalCache.consume(event)
|
||||
is LnZapPaymentResponseEvent -> LocalCache.consume(event)
|
||||
is LongTextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is PeopleListEvent -> LocalCache.consume(event)
|
||||
is ReactionEvent -> LocalCache.consume(event)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ReportEvent -> LocalCache.consume(event, relay)
|
||||
@@ -90,6 +99,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is PollNoteEvent -> LocalCache.consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
@@ -120,12 +130,21 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
auth(relay, challenge)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stop()
|
||||
Client.unsubscribe(clientListener)
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
resetFilters()
|
||||
@@ -152,7 +171,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
@@ -210,4 +229,5 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -11,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.compose.ui.text.capitalize
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -15,9 +15,16 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
val hashToLoad = hashtagToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
|
||||
tags = mapOf(
|
||||
"t" to listOf(
|
||||
hashToLoad,
|
||||
hashToLoad.lowercase(),
|
||||
hashToLoad.uppercase(),
|
||||
hashToLoad.capitalize()
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -43,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.followingKeySet()
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val followKeys = follows.map {
|
||||
it.substring(0, 6)
|
||||
@@ -54,7 +56,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
@@ -63,14 +65,14 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.followingTagSet()
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private var fromServiceHex: String,
|
||||
private var toUserHex: String,
|
||||
private var replyingToHex: String
|
||||
) : NostrDataSource("LnZapPaymentResponseFeed") {
|
||||
|
||||
val feedTypes = setOf(FeedType.WALLET_CONNECT)
|
||||
|
||||
private fun createWalletConnectServiceWatcher(): TypedFilter {
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = feedTypes,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapPaymentResponseEvent.kind),
|
||||
authors = listOf(fromServiceHex),
|
||||
tags = mapOf(
|
||||
"e" to listOf(replyingToHex),
|
||||
"p" to listOf(toUserHex)
|
||||
),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val channel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val wc = createWalletConnectServiceWatcher()
|
||||
|
||||
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
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
|
||||
@@ -39,7 +35,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return listOfNotNull(
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)
|
||||
@@ -47,7 +43,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
},
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
@@ -55,7 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
},
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
@@ -63,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
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
|
||||
@@ -40,7 +41,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
|
||||
@@ -2,24 +2,11 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import java.util.Date
|
||||
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
@@ -32,18 +19,17 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -60,12 +46,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
@@ -85,7 +69,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
return reactionsToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
@@ -94,7 +78,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
RepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind
|
||||
LnZapRequestEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -123,11 +109,17 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
|
||||
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PrivateDmEvent.kind,
|
||||
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
|
||||
HighlightEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -16,7 +16,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -31,7 +31,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -18,14 +18,14 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
.ifEmpty { null } ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
|
||||
val loadEventsChannel = requestNewChannel() { _, _ ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
|
||||
@@ -2,16 +2,8 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -30,7 +22,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -41,9 +33,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserPostsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
@@ -52,7 +44,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserReceivedZapsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
@@ -62,7 +54,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createFollowFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -73,7 +65,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createFollowersFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
@@ -83,7 +75,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createAcceptedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -94,7 +86,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createBookmarksFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -105,7 +97,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeAwardEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.tagSearch
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
|
||||
|
||||
open class BaseTextNoteEvent(
|
||||
id: HexKey,
|
||||
@@ -13,7 +15,49 @@ open class BaseTextNoteEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun mentions() = taggedUsers()
|
||||
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
private var citedUsersCache: Set<HexKey>? = null
|
||||
|
||||
fun citedUsers(): Set<HexKey> {
|
||||
citedUsersCache?.let { return it }
|
||||
|
||||
val matcher = tagSearch.matcher(content)
|
||||
val returningList = mutableSetOf<String>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { tags[it.toInt()] }
|
||||
if (tag != null && tag.size > 1 && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val matcher2 = nip19regex.matcher(content)
|
||||
while (matcher2.find()) {
|
||||
val uriScheme = matcher2.group(1) // nostr:
|
||||
val type = matcher2.group(2) // npub1
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
citedUsersCache = returningList
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun findCitations(): Set<String> {
|
||||
var citations = mutableSetOf<String>()
|
||||
@@ -22,15 +66,40 @@ open class BaseTextNoteEvent(
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { tags[it.toInt()] }
|
||||
if (tag != null && tag[0] == "e") {
|
||||
if (tag != null && tag.size > 1 && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
if (tag != null && tag[0] == "a") {
|
||||
if (tag != null && tag.size > 1 && tag[0] == "a") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val matcher2 = nip19regex.matcher(content)
|
||||
while (matcher2.find()) {
|
||||
val uriScheme = matcher2.group(1) // nostr:
|
||||
val type = matcher2.group(2) // npub1
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
if (tag != null && tag[0] == "a") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return citations
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
@@ -15,50 +12,7 @@ class BookmarkListEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30001
|
||||
|
||||
@@ -77,24 +31,7 @@ class BookmarkListEvent(
|
||||
createdAt: Long = Date().time / 1000
|
||||
): BookmarkListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
val content = Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
@@ -12,16 +12,15 @@ class ChannelMessageEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
|
||||
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 42
|
||||
|
||||
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
|
||||
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf(
|
||||
@@ -33,6 +32,9 @@ class ChannelMessageEvent(
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -38,23 +38,27 @@ open class Event(
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
val aTagValue = it[1]
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
ATag.parse(aTagValue, relay)
|
||||
}
|
||||
|
||||
override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
|
||||
|
||||
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
|
||||
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
|
||||
|
||||
override fun getPoWRank(): Int {
|
||||
var rank = 0
|
||||
@@ -79,7 +83,7 @@ open class Event(
|
||||
|
||||
override fun getReward(): BigDecimal? {
|
||||
return try {
|
||||
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
|
||||
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -203,6 +207,8 @@ open class Event(
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
|
||||
.registerTypeAdapter(Request::class.java, RequestDeserializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
@@ -222,11 +228,18 @@ open class Event(
|
||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
|
||||
|
||||
@@ -24,13 +24,15 @@ interface EventInterface {
|
||||
|
||||
fun hasValidSignature(): Boolean
|
||||
|
||||
fun isTaggedUser(loggedInUser: String): Boolean
|
||||
fun isTaggedUser(idHex: String): Boolean
|
||||
|
||||
fun isTaggedHash(hashtag: String): Boolean
|
||||
fun isTaggedHashes(hashtag: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtag: Set<String>): String?
|
||||
fun isTaggedHashes(hashtags: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtags: Set<String>): String?
|
||||
fun hashtags(): List<String>
|
||||
|
||||
fun getReward(): BigDecimal?
|
||||
fun getPoWRank(): Int
|
||||
|
||||
fun zapAddress(): String?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class FileHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
|
||||
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
|
||||
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 1063
|
||||
|
||||
private const val URL = "url"
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val DIMENSION = "dim"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
private const val BLUR_HASH = "blurhash"
|
||||
|
||||
fun create(
|
||||
url: String,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
encryptionKey: AESGCM? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): FileHeaderEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(URL, url),
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||
)
|
||||
|
||||
val content = description ?: ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AESGCM(val key: String, val nonce: String)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Base64
|
||||
import java.util.Date
|
||||
|
||||
class FileStorageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
|
||||
fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) }
|
||||
|
||||
fun decode(): ByteArray? {
|
||||
return try {
|
||||
Base64.getDecoder().decode(content)
|
||||
} catch (e: Exception) {
|
||||
Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 1064
|
||||
|
||||
private const val TYPE = "type"
|
||||
private const val DECRYPT = "decrypt"
|
||||
|
||||
fun encode(bytes: ByteArray): String {
|
||||
return Base64.getEncoder().encodeToString(bytes)
|
||||
}
|
||||
|
||||
fun create(
|
||||
mimeType: String,
|
||||
data: ByteArray,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): FileStorageEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(TYPE, mimeType)
|
||||
)
|
||||
|
||||
val content = encode(data)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class FileStorageHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
|
||||
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 1065
|
||||
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val DIMENSION = "dim"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
private const val BLUR_HASH = "blurhash"
|
||||
|
||||
fun create(
|
||||
storageEvent: FileStorageEvent,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
encryptionKey: AESGCM? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): FileStorageHeaderEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf("e", storageEvent.id),
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||
)
|
||||
|
||||
val content = description ?: ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import nostr.postr.Utils
|
||||
|
||||
abstract class GeneralListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = taggedEvents()
|
||||
fun bookmarkedPeople() = taggedUsers()
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createPrivateTags(
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
pubKey: ByteArray
|
||||
): String {
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
return Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class HighlightEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun inUrl() = taggedUrls().firstOrNull()
|
||||
fun author() = taggedUsers().firstOrNull()
|
||||
fun quote() = content
|
||||
|
||||
companion object {
|
||||
const val kind = 9802
|
||||
|
||||
fun create(
|
||||
msg: String,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PollNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,74 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LnZapEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
override fun zappedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun zappedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun amount(): BigDecimal? {
|
||||
return amount
|
||||
}
|
||||
|
||||
// Keeps this as a field because it's a heavier function used everywhere.
|
||||
val amount by lazy {
|
||||
try {
|
||||
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun containedPost(): Event? = try {
|
||||
description()?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
|
||||
null
|
||||
}
|
||||
|
||||
private fun lnInvoice(): String? = tags
|
||||
.filter { it.firstOrNull() == "bolt11" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
|
||||
private fun description(): String? = tags
|
||||
.filter { it.firstOrNull() == "description" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val kind = 9735
|
||||
}
|
||||
}
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
|
||||
class LnZapEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
// This event is also kept in LocalCache (same object)
|
||||
@Transient val zapRequest: LnZapRequestEvent?
|
||||
|
||||
override fun containedPost(): LnZapRequestEvent? = try {
|
||||
description()?.ifBlank { null }?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
} as? LnZapRequestEvent
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
|
||||
null
|
||||
}
|
||||
|
||||
init {
|
||||
zapRequest = containedPost()
|
||||
}
|
||||
|
||||
override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
override fun zappedPollOption(): Int? = try {
|
||||
zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
|
||||
null
|
||||
}
|
||||
|
||||
override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
|
||||
|
||||
override fun amount() = amount
|
||||
|
||||
// Keeps this as a field because it's a heavier function used everywhere.
|
||||
val amount by lazy {
|
||||
try {
|
||||
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
override fun content(): String {
|
||||
return content
|
||||
}
|
||||
|
||||
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
|
||||
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 9735
|
||||
}
|
||||
|
||||
enum class ZapType() {
|
||||
PUBLIC,
|
||||
PRIVATE,
|
||||
ANONYMOUS,
|
||||
NONZAP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ interface LnZapEventInterface : EventInterface {
|
||||
|
||||
fun zappedPost(): List<String>
|
||||
|
||||
fun zappedPollOption(): Int?
|
||||
|
||||
fun zappedAuthor(): List<String>
|
||||
|
||||
fun zappedRequestAuthor(): String?
|
||||
|
||||
fun taggedAddresses(): List<ATag>
|
||||
|
||||
fun amount(): BigDecimal?
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
import java.util.Date
|
||||
|
||||
class LnZapPaymentRequestEvent(
|
||||
@@ -16,11 +21,17 @@ class LnZapPaymentRequestEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun lnInvoice(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
|
||||
|
||||
val jsonText = Utils.decrypt(content, sharedSecret)
|
||||
|
||||
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
|
||||
|
||||
return (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
@@ -37,9 +48,10 @@ class LnZapPaymentRequestEvent(
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapPaymentRequestEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
|
||||
|
||||
val content = Utils.encrypt(
|
||||
lnInvoice,
|
||||
serializedRequest,
|
||||
privateKey,
|
||||
walletServicePubkey.toByteArray()
|
||||
)
|
||||
@@ -53,3 +65,40 @@ class LnZapPaymentRequestEvent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REQUEST OBJECTS
|
||||
|
||||
abstract class Request(var method: String? = null)
|
||||
|
||||
// PayInvoice Call
|
||||
class PayInvoiceParams(var invoice: String? = null)
|
||||
|
||||
class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
|
||||
|
||||
companion object {
|
||||
fun create(bolt11: String): PayInvoiceMethod {
|
||||
return PayInvoiceMethod(PayInvoiceParams(bolt11))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RequestDeserializer :
|
||||
JsonDeserializer<Request?> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type,
|
||||
context: JsonDeserializationContext
|
||||
): Request? {
|
||||
val jsonObject = json.asJsonObject
|
||||
val method = jsonObject.get("method")?.asString
|
||||
|
||||
if (method == "pay_invoice") {
|
||||
return context.deserialize<PayInvoiceMethod>(jsonObject, PayInvoiceMethod::class.java)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class LnZapPaymentResponseEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
val retVal = Utils.decrypt(content, sharedSecret)
|
||||
|
||||
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
|
||||
retVal.substring(16)
|
||||
} else {
|
||||
retVal
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
val decrypted = decrypt(privKey, pubKey)
|
||||
gson.fromJson(decrypted, Response::class.java)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 23195
|
||||
}
|
||||
}
|
||||
|
||||
// RESPONSE OBJECTS
|
||||
abstract class Response(
|
||||
@SerializedName("result_type")
|
||||
val resultType: String
|
||||
)
|
||||
|
||||
// PayInvoice Call
|
||||
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceResultParams(val preimage: String)
|
||||
}
|
||||
|
||||
class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceErrorParams(val code: ErrorType?, val message: String?)
|
||||
|
||||
enum class ErrorType {
|
||||
@SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"])
|
||||
RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds.
|
||||
@SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"])
|
||||
NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented.
|
||||
@SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"])
|
||||
INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount.
|
||||
@SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"])
|
||||
QUOTA_EXCEEDED, // The wallet has exceeded its spending quota.
|
||||
@SerializedName(value = "restricted", alternate = ["RESTRICTED"])
|
||||
RESTRICTED, // This public key is not allowed to do this operation.
|
||||
@SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"])
|
||||
UNAUTHORIZED, // This public key has no wallet connected.
|
||||
@SerializedName(value = "internal", alternate = ["INTERNAL"])
|
||||
INTERNAL, // An internal error.
|
||||
@SerializedName(value = "other", alternate = ["OTHER"])
|
||||
OTHER // Other error.
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseDeserializer :
|
||||
JsonDeserializer<Response?> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type,
|
||||
context: JsonDeserializationContext
|
||||
): Response? {
|
||||
val jsonObject = json.asJsonObject
|
||||
val resultType = jsonObject.get("result_type")?.asString
|
||||
|
||||
if (resultType == "pay_invoice") {
|
||||
val result = jsonObject.get("result")?.asJsonObject
|
||||
val error = jsonObject.get("error")?.asJsonObject
|
||||
if (result != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (error != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
} else {
|
||||
// tries to guess
|
||||
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,210 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class LnZapRequestEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
fun create(
|
||||
originalNote: EventInterface,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id()),
|
||||
listOf("p", originalNote.pubKey()),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(
|
||||
userHex: String,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("p", userHex),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
{
|
||||
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
|
||||
"content": "",
|
||||
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
|
||||
"created_at": 1674164539,
|
||||
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
|
||||
"kind": 9734,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
|
||||
],
|
||||
[
|
||||
"relays",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nostr-relay.wlvs.space",
|
||||
"wss://nostr.fmt.wiz.biz",
|
||||
"wss://relay.nostr.bg",
|
||||
"wss://nostr.oxtr.dev",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://brb.io",
|
||||
"wss://nostr.bitcoiner.social",
|
||||
"ws://monad.jb55.com:8080",
|
||||
"wss://relay.snort.social"
|
||||
]
|
||||
]
|
||||
}
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Utils
|
||||
import java.nio.charset.Charset
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class LnZapRequestEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
fun create(
|
||||
originalNote: EventInterface,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id()),
|
||||
listOf("p", originalNote.pubKey()),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
if (pollOption != null && pollOption >= 0) {
|
||||
tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
|
||||
}
|
||||
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
tags = tags + listOf(listOf("anon", ""))
|
||||
privkey = Utils.privkeyCreate()
|
||||
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = "" // make sure public content is empty, as the content is encrypted
|
||||
privkey = encryptionPrivateKey // sign event with generated privkey
|
||||
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privkey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(
|
||||
userHex: String,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("p", userHex),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
privkey = Utils.privkeyCreate()
|
||||
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
|
||||
tags = tags + listOf(listOf("anon", ""))
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = ""
|
||||
privkey = encryptionPrivateKey
|
||||
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey()
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privkey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
|
||||
var str = privkey + id + createdAt.toString()
|
||||
var strbyte = str.toByteArray(Charset.forName("utf-8"))
|
||||
return sha256.digest(strbyte)
|
||||
}
|
||||
|
||||
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
val iv = ByteArray(16)
|
||||
SecureRandom().nextBytes(iv)
|
||||
|
||||
val keySpec = SecretKeySpec(sharedSecret, "AES")
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
|
||||
var utf8message = msg.toByteArray(Charset.forName("utf-8"))
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
|
||||
val encryptedMsg = cipher.doFinal(utf8message)
|
||||
|
||||
val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32)
|
||||
val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32)
|
||||
|
||||
return encryptedMsgBech32 + "_" + ivBech32
|
||||
}
|
||||
|
||||
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
|
||||
throw IllegalArgumentException("Invalid shared secret size")
|
||||
}
|
||||
val parts = msg.split("_")
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("Invalid message format")
|
||||
}
|
||||
val iv = parts[1].run { Bech32.decode(this).second }
|
||||
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
|
||||
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
|
||||
|
||||
try {
|
||||
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
|
||||
return String(decryptedMsgBytes)
|
||||
} catch (ex: BadPaddingException) {
|
||||
throw IllegalArgumentException("Bad padding: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
|
||||
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null) {
|
||||
val encnote = anonTag[1]
|
||||
if (encnote.isNotBlank()) {
|
||||
try {
|
||||
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
|
||||
val decryptedEvent = fromJson(note)
|
||||
if (decryptedEvent.kind == 9733) {
|
||||
return decryptedEvent
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
{
|
||||
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
|
||||
"content": "",
|
||||
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
|
||||
"created_at": 1674164539,
|
||||
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
|
||||
"kind": 9734,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
|
||||
],
|
||||
[
|
||||
"relays",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nostr-relay.wlvs.space",
|
||||
"wss://nostr.fmt.wiz.biz",
|
||||
"wss://relay.nostr.bg",
|
||||
"wss://nostr.oxtr.dev",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://brb.io",
|
||||
"wss://nostr.bitcoiner.social",
|
||||
"ws://monad.jb55.com:8080",
|
||||
"wss://relay.snort.social"
|
||||
],
|
||||
[
|
||||
"poll_option", "n"
|
||||
]
|
||||
],
|
||||
"ots": <base64-encoded OTS file data> // TODO
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class PeopleListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30000
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PeopleListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
const val POLL_OPTION = "poll_option"
|
||||
const val VALUE_MAXIMUM = "value_maximum"
|
||||
const val VALUE_MINIMUM = "value_minimum"
|
||||
const val CONSENSUS_THRESHOLD = "consensus_threshold"
|
||||
const val CLOSED_AT = "closed_at"
|
||||
|
||||
class PollNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
// ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun pollOptions() =
|
||||
tags.filter { it.size > 2 && it[0] == POLL_OPTION }
|
||||
.associate { it[1].toInt() to it[2] }
|
||||
|
||||
fun getTagInt(property: String): Int? {
|
||||
val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
|
||||
|
||||
return if (number.isNullOrBlank() || number == "null") {
|
||||
null
|
||||
} else {
|
||||
number.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 6969
|
||||
|
||||
fun create(
|
||||
msg: String,
|
||||
replyTos: List<String>?,
|
||||
mentions: List<String>?,
|
||||
addresses: List<ATag>?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
pollOptions: Map<Int, String>,
|
||||
valueMaximum: Int?,
|
||||
valueMinimum: Int?,
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?,
|
||||
zapReceiver: String?
|
||||
): PollNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
pollOptions.forEach { poll_op ->
|
||||
tags.add(listOf(POLL_OPTION, poll_op.key.toString(), poll_op.value))
|
||||
}
|
||||
tags.add(listOf(VALUE_MAXIMUM, valueMaximum.toString()))
|
||||
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
|
||||
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
|
||||
tags.add(listOf(CLOSED_AT, closedAt.toString()))
|
||||
|
||||
if (zapReceiver != null) {
|
||||
tags.add(listOf("zap", zapReceiver))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
{
|
||||
"id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>
|
||||
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
|
||||
"created_at": <unix timestamp in seconds>,
|
||||
"kind": 6969,
|
||||
"tags": [
|
||||
["e", <32-bytes hex of the id of the poll event>, <primary poll host relay URL>],
|
||||
["p", <32-bytes hex of the key>, <primary poll host relay URL>],
|
||||
["poll_option", "0", "poll option 0 description string"],
|
||||
["poll_option", "1", "poll option 1 description string"],
|
||||
["poll_option", "n", "poll option <n> description string"],
|
||||
["value_maximum", "maximum satoshi value for inclusion in tally"],
|
||||
["value_minimum", "minimum satoshi value for inclusion in tally"],
|
||||
["consensus_threshold", "required percentage to attain consensus <0..100>"],
|
||||
["closed_at", "unix timestamp in seconds"],
|
||||
],
|
||||
"ots": <base64-encoded OTS file data>
|
||||
"content": <primary poll description string>,
|
||||
"sig": <64-bytes hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field>
|
||||
}
|
||||
*/
|
||||
@@ -21,7 +21,7 @@ class PrivateDmEvent(
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
* for initial messages.
|
||||
*/
|
||||
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
||||
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
||||
|
||||
/**
|
||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||
@@ -29,7 +29,7 @@ class PrivateDmEvent(
|
||||
* Nip-18 messages should refer to other events by inline references in the content like
|
||||
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
|
||||
*/
|
||||
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
@@ -58,6 +58,7 @@ class PrivateDmEvent(
|
||||
msg: String,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
publishedRecipientPubKey: ByteArray? = null,
|
||||
@@ -79,6 +80,9 @@ class PrivateDmEvent(
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
|
||||
@@ -14,8 +14,8 @@ class ReactionEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class RelayAuthEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
|
||||
fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 22242
|
||||
|
||||
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("relay", relay),
|
||||
listOf("challenge", challenge)
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class ReportEvent(
|
||||
}
|
||||
|
||||
fun reportedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
|
||||
.filter { it.size > 1 && it[0] == "e" }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
@@ -39,7 +39,7 @@ class ReportEvent(
|
||||
}
|
||||
|
||||
fun reportedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
|
||||
.filter { it.size > 1 && it[0] == "p" }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
|
||||
@@ -26,6 +26,7 @@ class TextNoteEvent(
|
||||
mentions: List<String>?,
|
||||
addresses: List<ATag>?,
|
||||
extraTags: List<String>?,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): TextNoteEvent {
|
||||
@@ -46,9 +47,13 @@ class TextNoteEvent(
|
||||
extraTags?.forEach {
|
||||
tags.add(listOf("t", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
findURLs(msg).forEach {
|
||||
tags.add(listOf("r", it))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toByteArray
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object Nip19 {
|
||||
@@ -12,19 +15,44 @@ object Nip19 {
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
val relay: String? = null,
|
||||
val author: String? = null,
|
||||
val kind: Long? = null,
|
||||
val additionalChars: String = ""
|
||||
)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
if (uri == null) return null
|
||||
|
||||
try {
|
||||
val matcher = nip19regex.matcher(uri)
|
||||
matcher.find()
|
||||
if (!matcher.find()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val uriScheme = matcher.group(1) // nostr:
|
||||
val type = matcher.group(2) // npub1
|
||||
val key = matcher.group(3) // bech32
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
val additionalChars = matcher.group(4) // additional chars
|
||||
|
||||
return parseComponents(uriScheme, type, key, additionalChars)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseComponents(
|
||||
uriScheme: String?,
|
||||
type: String,
|
||||
key: String?,
|
||||
additionalChars: String?
|
||||
): Return? {
|
||||
return try {
|
||||
val bytes = (type + key).bechToBytes()
|
||||
val parsed = when (type.lowercase()) {
|
||||
"npub1" -> npub(bytes)
|
||||
@@ -35,12 +63,11 @@ object Nip19 {
|
||||
"naddr1" -> naddr(bytes)
|
||||
else -> null
|
||||
}
|
||||
return parsed?.copy(additionalChars = additionalChars)
|
||||
parsed?.copy(additionalChars = additionalChars ?: "")
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun npub(bytes: ByteArray): Return {
|
||||
@@ -76,7 +103,15 @@ object Nip19 {
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
return Return(Type.EVENT, hex, relay)
|
||||
val author = tlv.get(Tlv.Type.AUTHOR.id)
|
||||
?.get(0)
|
||||
?.toHexKey()
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
|
||||
return Return(Type.EVENT, hex, relay, author, kind)
|
||||
}
|
||||
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
@@ -105,8 +140,31 @@ object Nip19 {
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay)
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
|
||||
}
|
||||
|
||||
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
|
||||
val kind = kind?.toByteArray()
|
||||
val author = author?.toByteArray()
|
||||
val idHex = idHex.toByteArray()
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
|
||||
|
||||
if (relay != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
|
||||
}
|
||||
|
||||
if (author != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author
|
||||
}
|
||||
|
||||
if (kind != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
|
||||
}
|
||||
|
||||
return Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
@@ -64,21 +65,49 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface, relay: String? = null) {
|
||||
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
|
||||
if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelay = relays.filter { it.url == relay }
|
||||
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
|
||||
|
||||
if (useConnectedRelay.isNotEmpty()) {
|
||||
useConnectedRelay.forEach {
|
||||
if (useConnectedRelayIfPresent.isNotEmpty()) {
|
||||
useConnectedRelayIfPresent.forEach {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
} else {
|
||||
/** temporary connection */
|
||||
Relay(relay, false, true, emptySet()).requestAndWatch() {
|
||||
it.send(signedEvent)
|
||||
it.disconnect()
|
||||
newSporadicRelay(
|
||||
relay,
|
||||
feedTypes,
|
||||
onConnected = { relay ->
|
||||
relay.send(signedEvent)
|
||||
},
|
||||
onDone = onDone
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.requestAndWatch {
|
||||
allSubscriptions().forEach {
|
||||
relay.sendFilter(requestId = it)
|
||||
}
|
||||
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(10000) // waits for a reply
|
||||
relay.disconnect()
|
||||
RelayPool.removeRelay(relay)
|
||||
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +160,14 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
@@ -167,5 +204,7 @@ object Client : RelayPool.Listener {
|
||||
* When an relay saves or rejects a new event.
|
||||
*/
|
||||
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
|
||||
|
||||
open fun onAuth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
class EOSETime(var time: Long)
|
||||
class EOSETime(var time: Long) {
|
||||
override fun toString(): String {
|
||||
return time.toString()
|
||||
}
|
||||
}
|
||||
|
||||
class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
|
||||
fun addOrUpdate(relayUrl: String, time: Long) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.google.gson.JsonElement
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -13,9 +14,11 @@ import okhttp3.WebSocketListener
|
||||
import java.util.Date
|
||||
|
||||
enum class FeedType {
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
|
||||
}
|
||||
|
||||
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
|
||||
class Relay(
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
@@ -95,9 +98,11 @@ class Relay(
|
||||
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
listeners.forEach {
|
||||
it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient))
|
||||
it.onEvent(this@Relay, channel, event)
|
||||
if (afterEOSE) {
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
}
|
||||
@@ -109,13 +114,17 @@ class Relay(
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onOK $url, $channel")
|
||||
Log.w("Relay", "Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}")
|
||||
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
|
||||
}
|
||||
"AUTH" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
|
||||
it.onAuth(this@Relay, msg[1].asString)
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay something else $url, $channel")
|
||||
it.onError(
|
||||
@@ -196,7 +205,7 @@ class Relay(
|
||||
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
|
||||
if (filters.isNotEmpty()) {
|
||||
val request =
|
||||
"""["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]"""
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
|
||||
// println("FILTERSSENT $url $request")
|
||||
socket?.send(request)
|
||||
eventUploadCounterInBytes += request.bytesUsedInMemory()
|
||||
@@ -224,11 +233,19 @@ class Relay(
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
if (write) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
|
||||
if (write) {
|
||||
if (signedEvent !is RelayAuthEvent) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) {
|
||||
@@ -266,6 +283,8 @@ class Relay(
|
||||
|
||||
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
|
||||
/**
|
||||
* Connected to or disconnected from a relay
|
||||
*
|
||||
|
||||
@@ -93,6 +93,8 @@ object RelayPool : Relay.Listener {
|
||||
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
|
||||
|
||||
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
}
|
||||
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||
@@ -113,6 +115,10 @@ object RelayPool : Relay.Listener {
|
||||
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@ import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.components.muted
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
@@ -54,6 +56,9 @@ class MainActivity : FragmentActivity() {
|
||||
null
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(this.applicationContext)
|
||||
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(this).components {
|
||||
if (SDK_INT >= 28) {
|
||||
@@ -88,6 +93,8 @@ class MainActivity : FragmentActivity() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// starts muted every time
|
||||
muted.value = true
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -7,13 +7,17 @@ import android.media.MediaScannerConnection
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.*
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object ImageSaver {
|
||||
/**
|
||||
@@ -74,6 +78,38 @@ object ImageSaver {
|
||||
})
|
||||
}
|
||||
|
||||
fun saveImage(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?
|
||||
) {
|
||||
try {
|
||||
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = localFile.inputStream().source().buffer()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
displayName = UUID.randomUUID().toString(),
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
contentResolver = context.contentResolver
|
||||
)
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = UUID.randomUUID().toString() + ".$extension",
|
||||
contentSource = buffer,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveContentQ(
|
||||
displayName: String,
|
||||
@@ -90,8 +126,14 @@ object ImageSaver {
|
||||
)
|
||||
}
|
||||
|
||||
val masterUri = if (contentType.startsWith("image")) {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
|
||||
val uri =
|
||||
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
|
||||
contentResolver.insert(masterUri, contentValues)
|
||||
checkNotNull(uri) {
|
||||
"Can't insert the new content"
|
||||
}
|
||||
|
||||
@@ -1,70 +1,127 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.io.InputStream
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
|
||||
object ImageUploader {
|
||||
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
server: ServersAvailable,
|
||||
context: Context,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String) -> Unit,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
val myServer = if (server == ServersAvailable.IMGUR) {
|
||||
ImgurServer()
|
||||
} else if (server == ServersAvailable.NOSTRIMG) {
|
||||
NostrImgServer()
|
||||
} else if (server == ServersAvailable.NOSTR_BUILD) {
|
||||
NostrBuildServer()
|
||||
} else {
|
||||
ImgurServer()
|
||||
}
|
||||
|
||||
val file = getRealPathFromURI(uri, context)?.let { File(it) } // create path from uri
|
||||
if (file != null) {
|
||||
uploadImage(file, imageInputStream, contentType, myServer, server, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadImage(
|
||||
file: File,
|
||||
inputStream: InputStream,
|
||||
contentType: String?,
|
||||
server: FileServer,
|
||||
serverType: ServersAvailable,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
|
||||
val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
category,
|
||||
"${UUID.randomUUID()}",
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
contentType?.toMediaType()
|
||||
if (serverType == ServersAvailable.NOSTR_BUILD) {
|
||||
requestBuilder.addHeader("Content-Type", "multipart/form-data")
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"fileToUpload",
|
||||
"$fileName.$extension",
|
||||
file.asRequestBody(contentType?.toMediaType())
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
)
|
||||
.build()
|
||||
} else {
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
category,
|
||||
"$fileName.$extension",
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
contentType?.toMediaType()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
|
||||
imageInputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("Authorization", "Client-ID e6aea87296f3f96")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
server.clientID()?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.postUrl(contentType))
|
||||
.post(requestBody)
|
||||
.build()
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val tree = jacksonObjectMapper().readTree(body.string())
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
val url = server.parseUrlFromSucess(body.string())
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
|
||||
onSuccess(url)
|
||||
onSuccess(url, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
@@ -79,3 +136,79 @@ object ImageUploader {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun getRealPathFromURI(uri: Uri, context: Context): String? {
|
||||
val returnCursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
returnCursor.moveToFirst()
|
||||
val name = returnCursor.getString(nameIndex)
|
||||
val file = File(context.filesDir, name)
|
||||
try {
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
val outputStream = FileOutputStream(file)
|
||||
var read = 0
|
||||
val maxBufferSize = 1 * 1024 * 1024
|
||||
val bytesAvailable: Int = inputStream?.available() ?: 0
|
||||
val bufferSize = Math.min(bytesAvailable, maxBufferSize)
|
||||
val buffers = ByteArray(bufferSize)
|
||||
while (inputStream?.read(buffers).also {
|
||||
if (it != null) {
|
||||
read = it
|
||||
}
|
||||
} != -1
|
||||
) {
|
||||
outputStream.write(buffers, 0, read)
|
||||
}
|
||||
Log.e("File Size", "Size " + file.length())
|
||||
inputStream?.close()
|
||||
outputStream.close()
|
||||
Log.e("File Path", "Path " + file.path)
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e("Exception", e.message!!)
|
||||
}
|
||||
return file.path
|
||||
}
|
||||
|
||||
abstract class FileServer {
|
||||
abstract fun postUrl(contentType: String?): String
|
||||
abstract fun parseUrlFromSucess(body: String): String?
|
||||
|
||||
open fun clientID(): String? = null
|
||||
}
|
||||
|
||||
class NostrImgServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
class ImgurServer : FileServer() {
|
||||
override fun postUrl(contentType: String?): String {
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
}
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun clientID() = "Client-ID e6aea87296f3f96"
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
val url = jacksonObjectMapper().readTree(body) // return url.toString()
|
||||
return url.toString().replace("\"", "")
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URL
|
||||
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError = MutableSharedFlow<String?>()
|
||||
var mediaType by mutableStateOf<String?>(null)
|
||||
|
||||
var selectedServer by mutableStateOf<ServersAvailable?>(null)
|
||||
var description by mutableStateOf("")
|
||||
|
||||
// Images and Videos
|
||||
var galleryUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
var uploadingPercentage = mutableStateOf(0.0f)
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
open fun load(account: Account, uri: Uri, contentType: String?) {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = defaultServer()
|
||||
|
||||
if (selectedServer == ServersAvailable.IMGUR) {
|
||||
selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
|
||||
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(context: Context) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val uri = galleryUri ?: return
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Loading"
|
||||
val contentType = contentResolver.getType(uri)
|
||||
contentResolver.openInputStream(uri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description)
|
||||
}
|
||||
?: run {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Uploading"
|
||||
ImageUploader.uploadImage(
|
||||
uri = uri,
|
||||
server = serverToUse,
|
||||
context = context,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
galleryUri = null
|
||||
isUploadingImage = false
|
||||
mediaType = null
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
description = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(15000)
|
||||
}
|
||||
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
try {
|
||||
val imageData = URL(imageUrl).readBytes()
|
||||
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(it)
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.20f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.30f
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
|
||||
if (nip95 != null) {
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second)
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
},
|
||||
onError = {
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.00f
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val context = LocalContext.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight().imePadding()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context)
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
) {
|
||||
ImageVideoPost(postViewModel, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = fileServers.map { it.second }
|
||||
val fileServerExplainers = fileServers.map { it.third }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
if (postViewModel.isImage() == true) {
|
||||
AsyncImage(
|
||||
model = postViewModel.galleryUri.toString(),
|
||||
contentDescription = postViewModel.galleryUri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.galleryUri?.let {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
postViewModel.imageUploadingError.emit("Unable to load file")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
postViewModel.selectedServer = fileServers[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
postViewModel.selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
postViewModel.selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = postViewModel.description,
|
||||
onValueChange = { postViewModel.description = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||
|
||||
fun addUserToMentions(user: User) {
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
}
|
||||
|
||||
fun addNoteToReplyTos(note: Note) {
|
||||
note.author?.let { addUserToMentions(it) }
|
||||
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
||||
}
|
||||
|
||||
fun tagIndex(user: User): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
}
|
||||
|
||||
fun tagIndex(note: Note): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
}
|
||||
|
||||
fun run() {
|
||||
// adds all references to mentions and reply tos
|
||||
message.split('\n').forEach { paragraph: String ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
addNoteToReplyTos(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags the text in the correct order.
|
||||
message = message.split('\n').map { paragraph: String ->
|
||||
paragraph.split(' ').map { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
val user = LocalCache.getOrCreateUser(results.key.hex)
|
||||
|
||||
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
"nostr:${note.idNote()}${results.restOfWord}"
|
||||
} else {
|
||||
word
|
||||
}
|
||||
} else {
|
||||
word
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
|
||||
@Composable
|
||||
fun NewPollClosing(pollViewModel: NewPostViewModel) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
pollViewModel.isValidClosedAt.value = true
|
||||
if (text.isNotEmpty()) {
|
||||
try {
|
||||
val int = text.toInt()
|
||||
if (int < 0) {
|
||||
pollViewModel.isValidClosedAt.value = false
|
||||
} else { pollViewModel.closedAt = int }
|
||||
} catch (e: Exception) { pollViewModel.isValidClosedAt.value = false }
|
||||
}
|
||||
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_closing_time),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_closing_time_days),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollClosingPreview() {
|
||||
NewPollClosing(NewPostViewModel())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
|
||||
@Composable
|
||||
fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
pollViewModel.isValidConsensusThreshold.value = true
|
||||
if (text.isNotEmpty()) {
|
||||
try {
|
||||
val int = text.toInt()
|
||||
if (int < 0 || int > 100) {
|
||||
pollViewModel.isValidConsensusThreshold.value = false
|
||||
} else { pollViewModel.consensusThreshold = int }
|
||||
} catch (e: Exception) { pollViewModel.isValidConsensusThreshold.value = false }
|
||||
}
|
||||
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_consensus_threshold),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_consensus_threshold_percent),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollConsensusThresholdPreview() {
|
||||
NewPollConsensusThreshold(NewPostViewModel())
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Composable
|
||||
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
|
||||
Row {
|
||||
val deleteIcon: @Composable (() -> Unit) = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
pollViewModel.pollOptions.remove(optionIndex)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.weight(1F),
|
||||
value = pollViewModel.pollOptions[optionIndex] ?: "",
|
||||
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_option_description),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
// colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else colorInValid,
|
||||
trailingIcon = if (optionIndex > 1) deleteIcon else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollOptionPreview() {
|
||||
NewPollOption(NewPostViewModel(), 0)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
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.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var isInputValid = true
|
||||
if (pollViewModel.message.text.isEmpty()) {
|
||||
isInputValid = false
|
||||
}
|
||||
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pollViewModel.message,
|
||||
onValueChange = {
|
||||
pollViewModel.updateMessage(it)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_primary_description),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_primary_description),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
colors = if (isInputValid) colorValid else colorInValid,
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
|
||||
@Composable
|
||||
fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) {
|
||||
// if no recipients, add user's pubkey
|
||||
if (pollViewModel.zapRecipients.isEmpty()) {
|
||||
pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
// TODO allow add multiple recipients and check input validity
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
value = pollViewModel.zapRecipients[0],
|
||||
onValueChange = { /* TODO */ },
|
||||
enabled = false, // TODO enable add recipients
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_recipients),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_recipients),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
|
||||
val pollViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
pollViewModel.load(account, baseReplyTo, quote)
|
||||
delay(100)
|
||||
|
||||
pollViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.imePadding()
|
||||
.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
pollViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PollButton(
|
||||
onPost = {
|
||||
pollViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = pollViewModel.message.text.isNotBlank() &&
|
||||
pollViewModel.pollOptions.values.all { it.isNotEmpty() } &&
|
||||
pollViewModel.isValidRecipients.value &&
|
||||
pollViewModel.isValidvalueMaximum.value &&
|
||||
pollViewModel.isValidvalueMinimum.value &&
|
||||
pollViewModel.isValidConsensusThreshold.value &&
|
||||
pollViewModel.isValidClosedAt.value
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "✖ ") {
|
||||
pollViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.poll_heading_required))
|
||||
// NewPollRecipientsField(pollViewModel, account)
|
||||
NewPollPrimaryDescription(pollViewModel)
|
||||
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(pollViewModel, index)
|
||||
}
|
||||
Button(
|
||||
onClick = { pollViewModel.pollOptions[pollViewModel.pollOptions.size] = "" },
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Text(stringResource(R.string.poll_heading_optional))
|
||||
NewPollVoteValueRange(pollViewModel)
|
||||
NewPollConsensusThreshold(pollViewModel)
|
||||
NewPollClosing(pollViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
val userSuggestions = pollViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserLine(item, account) {
|
||||
pollViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
/*UploadFromGallery(
|
||||
isUploading = pollViewModel.isUploadingImage
|
||||
) {
|
||||
pollViewModel.upload(it, context)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.post_poll), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
/*@Preview
|
||||
@Composable
|
||||
fun NewPollViewPreview() {
|
||||
NewPollView(onClose = {}, account = Account(loggedIn = Persona()))
|
||||
}*/
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Composable
|
||||
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
|
||||
var textMax by rememberSaveable { mutableStateOf("") }
|
||||
var textMin by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
// check for zapMax amounts < 1
|
||||
pollViewModel.isValidvalueMaximum.value = true
|
||||
if (textMax.isNotEmpty()) {
|
||||
try {
|
||||
val int = textMax.toInt()
|
||||
if (int < 1) {
|
||||
pollViewModel.isValidvalueMaximum.value = false
|
||||
} else { pollViewModel.valueMaximum = int }
|
||||
} catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false }
|
||||
}
|
||||
|
||||
// check for minZap amounts < 1
|
||||
pollViewModel.isValidvalueMinimum.value = true
|
||||
if (textMin.isNotEmpty()) {
|
||||
try {
|
||||
val int = textMin.toInt()
|
||||
if (int < 1) {
|
||||
pollViewModel.isValidvalueMinimum.value = false
|
||||
} else { pollViewModel.valueMinimum = int }
|
||||
} catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false }
|
||||
}
|
||||
|
||||
// check for zapMin > zapMax
|
||||
if (textMin.isNotEmpty() && textMax.isNotEmpty()) {
|
||||
try {
|
||||
val intMin = textMin.toInt()
|
||||
val intMax = textMax.toInt()
|
||||
|
||||
if (intMin > intMax) {
|
||||
pollViewModel.isValidvalueMinimum.value = false
|
||||
pollViewModel.isValidvalueMaximum.value = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pollViewModel.isValidvalueMinimum.value = false
|
||||
pollViewModel.isValidvalueMaximum.value = false
|
||||
}
|
||||
}
|
||||
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = textMin,
|
||||
onValueChange = { textMin = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_value_min),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.sats),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = textMax,
|
||||
onValueChange = { textMax = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_value_max),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.sats),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollVoteValueRangePreview() {
|
||||
NewPollVoteValueRange(NewPostViewModel())
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -10,9 +16,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowForwardIos
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
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.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -21,17 +38,22 @@ 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.graphics.asImageBitmap
|
||||
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.font.FontWeight
|
||||
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.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -39,12 +61,17 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -104,8 +131,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.message.text.isNotBlank() &&
|
||||
!postViewModel.isUploadingImage
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,13 +187,72 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(postViewModel, index)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" },
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val url = postViewModel.contentToAddUrl
|
||||
if (url != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
account.defaultFileServer,
|
||||
onAdd = { description, server ->
|
||||
postViewModel.upload(url, description, server, context)
|
||||
account.changeDefaultFileServer(server)
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val user = postViewModel.account?.userProfile()
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
|
||||
if (lud16 != null && postViewModel.wantsInvoice) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
user.pubkeyHex,
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl =
|
||||
myUrlPreview.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it, true) }) {
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
@@ -182,11 +267,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it, true) }) {
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
VideoView(myUrlPreview)
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview)
|
||||
}
|
||||
} else if (isBechLink(myUrlPreview)) {
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
MaterialTheme.colors.background,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview)
|
||||
}
|
||||
@@ -214,13 +307,36 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(bottom = 10.dp)
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier
|
||||
) {
|
||||
postViewModel.upload(it, context)
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
val hashtag = stringResource(R.string.poll_hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddInvoice) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
}
|
||||
|
||||
ForwardZapTo(postViewModel) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +345,143 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddPollButton(
|
||||
isPollActive: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
if (!isPollActive) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_poll),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_lists),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddLnInvoiceButton(
|
||||
isLnInvoiceActive: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
if (!isLnInvoiceActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ForwardZapTo(
|
||||
postViewModel: NewPostViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp)
|
||||
) {
|
||||
if (!postViewModel.wantsForwardZapTo) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowForwardIos,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowForwardIos,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsForwardZapTo) {
|
||||
OutlinedTextField(
|
||||
value = postViewModel.forwardZapToEditting,
|
||||
onValueChange = {
|
||||
postViewModel.updateZapForwardTo(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(0.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.zap_forward_lnAddress),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CloseButton(onCancel: () -> Unit) {
|
||||
Button(
|
||||
@@ -330,3 +583,210 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ServersAvailable {
|
||||
IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
defaultServer: ServersAvailable,
|
||||
onAdd: (String, ServersAvailable) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isImage = mediaType.startsWith("image")
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = fileServers.map { it.second }
|
||||
val fileServerExplainers = fileServers.map { it.third }
|
||||
|
||||
var selectedServer by remember { mutableStateOf(defaultServer) }
|
||||
var message by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isImage) {
|
||||
R.string.content_description_add_image
|
||||
} else {
|
||||
if (isVideo) {
|
||||
R.string.content_description_add_video
|
||||
} else {
|
||||
R.string.content_description_add_document
|
||||
}
|
||||
}
|
||||
),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1.0f)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = onCancel
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cancel,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(30.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
if (mediaType.startsWith("image")) {
|
||||
AsyncImage(
|
||||
model = uri.toString(),
|
||||
contentDescription = uri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
} else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = uri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(uri)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
selectedServer = fileServers[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
onAdd(message, selectedServer)
|
||||
},
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
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.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -19,9 +23,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NewPostViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var originalNote: Note? = null
|
||||
open class NewPostViewModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
var originalNote: Note? = null
|
||||
|
||||
var mentions by mutableStateOf<List<User>?>(null)
|
||||
var replyTos by mutableStateOf<List<Note>?>(null)
|
||||
@@ -33,8 +37,37 @@ class NewPostViewModel : ViewModel() {
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
var userSuggestionsMainMessage: Boolean? = null
|
||||
|
||||
fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(null)
|
||||
|
||||
// Polls
|
||||
var canUsePoll by mutableStateOf(false)
|
||||
var wantsPoll by mutableStateOf(false)
|
||||
var zapRecipients = mutableStateListOf<HexKey>()
|
||||
var pollOptions = newStateMapPollOptions()
|
||||
var valueMaximum: Int? = null
|
||||
var valueMinimum: Int? = null
|
||||
var consensusThreshold: Int? = null
|
||||
var closedAt: Int? = null
|
||||
|
||||
var isValidRecipients = mutableStateOf(true)
|
||||
var isValidvalueMaximum = mutableStateOf(true)
|
||||
var isValidvalueMinimum = mutableStateOf(true)
|
||||
var isValidConsensusThreshold = mutableStateOf(true)
|
||||
var isValidClosedAt = mutableStateOf(true)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
// Forward Zap to
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
var forwardZapTo by mutableStateOf<User?>(null)
|
||||
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
@@ -49,131 +82,117 @@ class NewPostViewModel : ViewModel() {
|
||||
this.mentions = currentMentions.plus(replyUser)
|
||||
}
|
||||
}
|
||||
} ?: {
|
||||
} ?: run {
|
||||
replyTos = null
|
||||
mentions = null
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
|
||||
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
canAddInvoice = account.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
forwardZapTo = null
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
this.account = account
|
||||
}
|
||||
|
||||
fun addUserToMentions(user: User) {
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
}
|
||||
|
||||
fun addNoteToReplyTos(note: Note) {
|
||||
note.author?.let { addUserToMentions(it) }
|
||||
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
||||
}
|
||||
|
||||
fun tagIndex(user: User): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
}
|
||||
|
||||
fun tagIndex(note: Note): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
// adds all references to mentions and reply tos
|
||||
message.text.split('\n').forEach { paragraph: String ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
|
||||
tagger.run()
|
||||
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
addNoteToReplyTos(note)
|
||||
}
|
||||
}
|
||||
val zapReceiver = if (wantsForwardZapTo) {
|
||||
if (forwardZapTo != null) {
|
||||
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
|
||||
} else {
|
||||
forwardZapToEditting.text
|
||||
}
|
||||
}
|
||||
|
||||
// Tags the text in the correct order.
|
||||
val newMessage = message.text.split('\n').map { paragraph: String ->
|
||||
paragraph.split(' ').map { word: String ->
|
||||
val results = parseDirtyWordForKey(word)
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
val user = LocalCache.getOrCreateUser(results.key.hex)
|
||||
|
||||
"#[${tagIndex(user)}]${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
} else {
|
||||
word
|
||||
}
|
||||
} else {
|
||||
word
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
|
||||
if (originalNote?.channel() != null) {
|
||||
account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
|
||||
} else {
|
||||
account?.sendPost(newMessage, replyTos, mentions)
|
||||
null
|
||||
}
|
||||
|
||||
message = TextFieldValue("")
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mentions = null
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver)
|
||||
} else if (originalNote?.channel() != null) {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver)
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver)
|
||||
} else {
|
||||
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
fun upload(it: Uri, context: Context) {
|
||||
fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
contentResolver = context.contentResolver,
|
||||
onSuccess = { imageUrl ->
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(2000)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
if (server == ServersAvailable.NIP95) {
|
||||
val contentType = contentResolver.getType(it)
|
||||
contentResolver.openInputStream(it)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
server = server,
|
||||
context = context,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94 || server == ServersAvailable.NOSTR_BUILD_NIP_94) {
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
contentToAddUrl = null
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mentions = null
|
||||
|
||||
wantsPoll = false
|
||||
zapRecipients = mutableStateListOf<HexKey>()
|
||||
pollOptions = newStateMapPollOptions()
|
||||
valueMaximum = null
|
||||
valueMinimum = null
|
||||
consensusThreshold = null
|
||||
closedAt = null
|
||||
|
||||
wantsInvoice = false
|
||||
|
||||
wantsForwardZapTo = false
|
||||
forwardZapTo = null
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
userSuggestions = emptyList()
|
||||
userSuggestionAnchor = null
|
||||
userSuggestionsMainMessage = null
|
||||
}
|
||||
|
||||
fun findUrlInMessage(): String? {
|
||||
open fun findUrlInMessage(): String? {
|
||||
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
paragraph.split(' ').firstOrNull { word: String ->
|
||||
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
|
||||
@@ -181,17 +200,18 @@ class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFromReplyList(it: User) {
|
||||
open fun removeFromReplyList(it: User) {
|
||||
mentions = mentions?.minus(it)
|
||||
}
|
||||
|
||||
fun updateMessage(it: TextFieldValue) {
|
||||
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 = true
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
} else {
|
||||
@@ -200,18 +220,138 @@ class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun autocompleteWithUser(item: User) {
|
||||
userSuggestionAnchor?.let {
|
||||
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
open fun updateZapForwardTo(it: TextFieldValue) {
|
||||
forwardZapToEditting = it
|
||||
if (it.selection.collapsed) {
|
||||
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
userSuggestionAnchor = it.selection
|
||||
userSuggestionsMainMessage = false
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
} else {
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun autocompleteWithUser(item: User) {
|
||||
userSuggestionAnchor?.let {
|
||||
if (userSuggestionsMainMessage == true) {
|
||||
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)
|
||||
)
|
||||
} else {
|
||||
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
forwardZapTo = item
|
||||
|
||||
forwardZapToEditting = TextFieldValue(
|
||||
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
}
|
||||
|
||||
message = TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
userSuggestionAnchor = null
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
|
||||
return mutableStateMapOf(Pair(0, ""), Pair(1, ""))
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
|
||||
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
|
||||
}
|
||||
|
||||
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
|
||||
if (include) {
|
||||
updateMessage(TextFieldValue(message.text + " $hashtag"))
|
||||
} else {
|
||||
updateMessage(
|
||||
TextFieldValue(
|
||||
message.text.replace(" $hashtag", "")
|
||||
.replace(hashtag, "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendHeader(it)
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
if (note == null) {
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
} else {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
note?.let {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectImage(uri: Uri) {
|
||||
contentToAddUrl = uri
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,8 +170,10 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
server = account.defaultFileServer,
|
||||
context = context,
|
||||
contentResolver = context.contentResolver,
|
||||
onSuccess = { imageUrl ->
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
onUploading(false)
|
||||
onUploaded(imageUrl)
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A button to save the remote image to the gallery.
|
||||
@@ -83,3 +84,63 @@ fun SaveToGallery(url: String) {
|
||||
Text(text = stringResource(id = R.string.save), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(localFile: File, mimeType: String?) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
localContext,
|
||||
localContext.getString(R.string.image_saved_to_the_gallery),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
localContext,
|
||||
localContext.getString(R.string.failed_to_save_the_image),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val writeStoragePermissionState = rememberPermissionState(
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
|
||||
saveImage()
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.save), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.vitorpamplona.amethyst.ui.buttons
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPollView
|
||||
|
||||
@Composable
|
||||
fun FabColumn(account: Account) {
|
||||
var isOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var wantsToPoll by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Column() {
|
||||
if (isOpen) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
wantsToPoll = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Modifier.size(45.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_poll),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
wantsToPost = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Modifier.size(45.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_lists),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { isOpen = !isOpen },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
// NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
|
||||
}
|
||||
|
||||
if (wantsToPoll) {
|
||||
NewPollView({ wantsToPoll = false }, account = account)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.vitorpamplona.amethyst.buttons
|
||||
package com.vitorpamplona.amethyst.ui.buttons
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -16,18 +16,20 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewNoteButton(account: Account) {
|
||||
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
NewPostView({ wantsToPost = false }, account = account)
|
||||
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
|
||||
@@ -22,7 +22,7 @@ data class ResizeImage(val url: String?, val size: Dp) {
|
||||
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
|
||||
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
|
||||
|
||||
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
return url // "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* This class is designed to have a waiting time between two calls of invalidate
|
||||
@@ -44,3 +45,37 @@ class BundledUpdate(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is designed to have a waiting time between two calls of invalidate
|
||||
*/
|
||||
class BundledInsert<T>(
|
||||
val delay: Long,
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
private var onlyOneInBlock = AtomicBoolean()
|
||||
private var atomicSet = AtomicReference<Set<T>>(setOf<T>())
|
||||
|
||||
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
|
||||
atomicSet.updateAndGet() {
|
||||
it + newObject
|
||||
}
|
||||
|
||||
if (onlyOneInBlock.getAndSet(true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + dispatcher)
|
||||
scope.launch {
|
||||
try {
|
||||
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||
delay(delay)
|
||||
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
onlyOneInBlock.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
|
||||
@Composable
|
||||
fun ClickableNoteTag(
|
||||
baesNote: Note,
|
||||
baseNote: Note,
|
||||
navController: NavController
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"),
|
||||
onClick = { navController.navigate("Note/${baesNote.idHex}") },
|
||||
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
|
||||
onClick = { navController.navigate("Note/${baseNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
fun ClickableUrl(urlText: String, url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(urlText),
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,34 @@ import android.net.Uri
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun MayBeWithdrawal(lnurlWord: String) {
|
||||
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnurlWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
|
||||
}
|
||||
}
|
||||
|
||||
lnWithdrawal?.let {
|
||||
ClickableWithdrawal(withdrawalString = it)
|
||||
}
|
||||
?: Text(
|
||||
text = "$lnurlWord ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableWithdrawal(withdrawalString: String) {
|
||||
|
||||
@@ -9,13 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -24,22 +19,48 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String) {
|
||||
val amount = try {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnbcWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
|
||||
if (myInvoice != null) {
|
||||
val myInvoiceAmount = try {
|
||||
LnInvoiceUtil.getAmountInSats(myInvoice)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice = Pair(myInvoice, myInvoiceAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lnInvoice?.let {
|
||||
InvoicePreview(it.first, it.second)
|
||||
}
|
||||
?: Text(
|
||||
text = "$lnbcWord ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -36,14 +34,21 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit) {
|
||||
fun InvoiceRequest(
|
||||
lud16: String,
|
||||
toUserPubKeyHex: String,
|
||||
account: Account,
|
||||
titleText: String? = null,
|
||||
buttonText: String? = null,
|
||||
onSuccess: (String) -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -73,7 +78,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.lightning_tips),
|
||||
text = titleText ?: stringResource(R.string.lightning_tips),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
@@ -130,20 +135,14 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message)
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
zapRequest?.toJson(),
|
||||
onSuccess = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
||||
startActivity(context, intent, null)
|
||||
}
|
||||
onClose()
|
||||
},
|
||||
onSuccess = onSuccess,
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
@@ -159,7 +158,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
|
||||
Text(text = buttonText ?: stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -14,7 +13,7 @@ import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -34,20 +33,25 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
@@ -55,7 +59,7 @@ val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\
|
||||
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
|
||||
|
||||
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
val urlPattern: Pattern = Patterns.WEB_URL
|
||||
|
||||
fun isValidURL(url: String?): Boolean {
|
||||
@@ -81,114 +85,133 @@ fun RichTextViewer(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.background(
|
||||
MaterialTheme.colors.onSurface
|
||||
.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor)
|
||||
)
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(modifier = modifier.animateContentSize()) {
|
||||
if (content.startsWith("# ") ||
|
||||
Column(modifier = modifier) {
|
||||
if (content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("**") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.background(
|
||||
MaterialTheme.colors.onSurface
|
||||
.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor)
|
||||
)
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
|
||||
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
) {
|
||||
Markdown(
|
||||
content = content,
|
||||
content = markdownWithSpecialContent,
|
||||
markdownParseOptions = MarkdownParseOptions.Default
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val imagesForPager = mutableListOf<String>()
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
// sequence of images will render in a slideview
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { word.endsWith(it, true) }) {
|
||||
imagesForPager.add(word)
|
||||
}
|
||||
if (videoExtensions.any { word.endsWith(it, true) }) {
|
||||
imagesForPager.add(word)
|
||||
}
|
||||
}
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlVideo(fullUrl)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
content.split('\n').forEach { paragraph ->
|
||||
FlowRow() {
|
||||
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ')
|
||||
val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ')
|
||||
s.forEach { word: String ->
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
val lnInvoice = LnInvoiceUtil.findInvoice(word)
|
||||
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
||||
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { word.endsWith(it, true) }) {
|
||||
ZoomableImageView(word, imagesForPager)
|
||||
} else if (videoExtensions.any { word.endsWith(it, true) }) {
|
||||
ZoomableImageView(word, imagesForPager)
|
||||
} else {
|
||||
UrlPreview(word, "$word ")
|
||||
}
|
||||
} else if (lnInvoice != null) {
|
||||
InvoicePreview(lnInvoice)
|
||||
} else if (lnWithdrawal != null) {
|
||||
ClickableWithdrawal(withdrawalString = lnWithdrawal)
|
||||
val img = imagesForPager[word]
|
||||
if (img != null) {
|
||||
ZoomableContentView(img, imageList)
|
||||
} else if (urlSet.contains(word)) {
|
||||
UrlPreview(word, "$word ")
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
MayBeInvoicePreview(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
MayBeWithdrawal(word)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
BechLink(
|
||||
word,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, accountViewModel, navController)
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -196,26 +219,63 @@ fun RichTextViewer(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (isValidURL(word)) {
|
||||
if (urlSet.contains(word)) {
|
||||
ClickableUrl("$word ", word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
||||
if (lnWithdrawal != null) {
|
||||
ClickableWithdrawal(withdrawalString = lnWithdrawal)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
BechLink(
|
||||
word,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, accountViewModel, navController)
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -230,50 +290,149 @@ fun RichTextViewer(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
|
||||
if (parsedNip19.type == Nip19.Type.USER) {
|
||||
val userHex = parsedNip19.hex
|
||||
val userBase = LocalCache.getOrCreateUser(userHex)
|
||||
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val displayName = userState?.user?.bestDisplayName()
|
||||
if (displayName !== null) {
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun returnMarkdownWithSpecialContent(content: String): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isValidURL(word)) {
|
||||
returnContent += "[$word]($word) "
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
returnContent += "[$word](mailto:$word) "
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
returnContent += "[$word](tel:$word) "
|
||||
} else if (isBechLink(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
returnContent += if (parsedNip19 !== null) {
|
||||
val displayName = getDisplayNameFromUserNip19(parsedNip19)
|
||||
if (displayName != null) {
|
||||
"[@$displayName](nostr://$word) "
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
}
|
||||
returnContent += "\n"
|
||||
}
|
||||
return returnContent
|
||||
}
|
||||
|
||||
private fun isArabic(text: String): Boolean {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
fun isBechLink(word: String): Boolean {
|
||||
val cleaned = word.removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||
|
||||
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it, true) }
|
||||
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BechLink(word: String, navController: NavController) {
|
||||
val nip19Route = Nip19.uriToRoute(word)
|
||||
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
|
||||
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
|
||||
|
||||
if (nip19Route == null) {
|
||||
Text(text = "$word ")
|
||||
LaunchedEffect(key1 = word) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Nip19.uriToRoute(word)?.let {
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
baseNotePair = Pair(note, it.additionalChars)
|
||||
}
|
||||
} else {
|
||||
nip19Route = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canPreview) {
|
||||
baseNotePair?.let {
|
||||
NoteCompose(
|
||||
baseNote = it.first,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
if (!it.second.isNullOrEmpty()) {
|
||||
Text(
|
||||
"${it.second} "
|
||||
)
|
||||
}
|
||||
} ?: nip19Route?.let {
|
||||
ClickableRoute(it, navController)
|
||||
} ?: Text(text = "$word ")
|
||||
} else {
|
||||
ClickableRoute(nip19Route, navController)
|
||||
nip19Route?.let {
|
||||
ClickableRoute(it, navController)
|
||||
} ?: Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
fun HashTag(word: String, navController: NavController) {
|
||||
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
|
||||
|
||||
val (tag, suffix) = try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
LaunchedEffect(key1 = word) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
val (myTag, mySuffix) = try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (myTag != null) {
|
||||
tagSuffixPair = Pair(myTag, mySuffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tag != null) {
|
||||
val hashtagIcon = checkForHashtagWithIcon(tag)
|
||||
tagSuffixPair?.let { tagPair ->
|
||||
val hashtagIcon = checkForHashtagWithIcon(tagPair.first)
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("#$tag")
|
||||
append("#${tagPair.first}")
|
||||
}
|
||||
},
|
||||
onClick = { navController.navigate("Hashtag/$tag") }
|
||||
onClick = { navController.navigate("Hashtag/${tagPair.first}") }
|
||||
)
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
@@ -296,14 +455,12 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
if (hashtagIcon != null) {
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = hashtagIcon.modifier
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = hashtagIcon.modifier
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -314,75 +471,80 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
Text(text = "$suffix ")
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
tagPair.second?.ifBlank { "" }?.let {
|
||||
Text(text = "$it ")
|
||||
}
|
||||
} ?: Text(text = "$word ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
|
||||
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
|
||||
|
||||
val (index, extraCharacters) = try {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (index == null) {
|
||||
return Text(text = "$word ")
|
||||
}
|
||||
|
||||
if (index >= 0 && index < tags.size) {
|
||||
if (tags[index][0] == "p") {
|
||||
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
|
||||
if (baseUser != null) {
|
||||
val userState = baseUser.live().metadata.observeAsState()
|
||||
val user = userState.value?.user
|
||||
if (user != null) {
|
||||
ClickableUserTag(user, navController)
|
||||
Text(text = "$extraCharacters ")
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
Text(text = "$word ")
|
||||
LaunchedEffect(key1 = word) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
val (index, suffix) = try {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
} else if (tags[index][0] == "e") {
|
||||
val note = LocalCache.checkGetOrCreateNote(tags[index][1])
|
||||
if (note != null) {
|
||||
if (canPreview) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
ClickableNoteTag(note, navController)
|
||||
Text(text = "$extraCharacters ")
|
||||
|
||||
if (index != null && index >= 0 && index < tags.size) {
|
||||
val tag = tags[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
baseUserPair = Pair(it, suffix)
|
||||
}
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
LocalCache.checkGetOrCreateNote(tag[1])?.let {
|
||||
baseNotePair = Pair(it, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
|
||||
baseUserPair?.let {
|
||||
ClickableUserTag(it.first, navController)
|
||||
Text(text = "${it.second} ")
|
||||
}
|
||||
|
||||
baseNotePair?.let {
|
||||
if (canPreview) {
|
||||
NoteCompose(
|
||||
baseNote = it.first,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
it.second?.ifBlank { null }?.let {
|
||||
Text(text = "$it ")
|
||||
}
|
||||
} else {
|
||||
ClickableNoteTag(it.first, navController)
|
||||
Text(text = "${it.second} ")
|
||||
}
|
||||
}
|
||||
|
||||
if (baseNotePair == null && baseUserPair == null) {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import okio.Buffer
|
||||
import java.security.MessageDigest
|
||||
|
||||
@@ -61,6 +62,7 @@ private fun svgString(msg: String): String {
|
||||
|
||||
class HashImageFetcher(
|
||||
private val context: Context,
|
||||
private val size: Size,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
@@ -79,16 +81,17 @@ class HashImageFetcher(
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return HashImageFetcher(options.context, data)
|
||||
return HashImageFetcher(options.context, options.size, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
object Robohash {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("robohash:$message")
|
||||
.fetcherFactory(HashImageFetcher.Factory)
|
||||
.size(robotSize)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.size.Size
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImage(
|
||||
robot: String,
|
||||
robotSize: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
|
||||
@@ -26,24 +30,33 @@ fun RobohashAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
AsyncImage(
|
||||
model = Robohash.imageRequest(LocalContext.current, robot),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
with(LocalDensity.current) {
|
||||
AsyncImage(
|
||||
model = Robohash.imageRequest(
|
||||
LocalContext.current,
|
||||
robot,
|
||||
Size(robotSize.roundToPx(), robotSize.roundToPx())
|
||||
),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var imageErrors = setOf<String>()
|
||||
|
||||
@Composable
|
||||
fun RobohashFallbackAsyncImage(
|
||||
robot: String,
|
||||
model: String?,
|
||||
robotSize: Dp,
|
||||
model: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
@@ -52,22 +65,47 @@ fun RobohashFallbackAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot))
|
||||
if (imageErrors.contains(model)) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
robotSize = robotSize,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val painter = with(LocalDensity.current) {
|
||||
rememberAsyncImagePainter(
|
||||
model = Robohash.imageRequest(
|
||||
context,
|
||||
robot,
|
||||
Size(robotSize.roundToPx(), robotSize.roundToPx())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
onError = {
|
||||
imageErrors = imageErrors + model
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -82,9 +120,11 @@ fun RobohashAsyncImageProxy(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
if (model.url == null) {
|
||||
val proxy = model.proxyUrl()
|
||||
if (proxy == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
robotSize = model.size,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
@@ -96,7 +136,8 @@ fun RobohashAsyncImageProxy(
|
||||
} else {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robot,
|
||||
model = model.proxyUrl(),
|
||||
robotSize = model.size,
|
||||
model = proxy,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
@@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.PagerState
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SlidingCarousel(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -39,7 +39,7 @@ fun SlidingCarousel(
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
HorizontalPager(count = itemsCount, state = pagerState) { page ->
|
||||
HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
|
||||
itemContent(page)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -26,10 +29,18 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
fun TextSpinner(
|
||||
label: String,
|
||||
placeholder: String,
|
||||
options: List<String>,
|
||||
explainers: List<String>? = null,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var optionsShowing by remember { mutableStateOf(false) }
|
||||
@@ -62,7 +73,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
|
||||
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
@@ -72,7 +83,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
|
||||
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
border = BorderStroke(0.25.dp, Color.LightGray),
|
||||
@@ -81,7 +92,6 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
|
||||
LazyColumn() {
|
||||
itemsIndexed(options) { index, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 16.dp)
|
||||
@@ -89,7 +99,25 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
|
||||
onSelect(index)
|
||||
}
|
||||
) {
|
||||
Text(text = item, color = MaterialTheme.colors.onSurface)
|
||||
Column() {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = item, color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
explainers?.getOrNull(index)?.let {
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
Divider(color = Color.LightGray, thickness = 0.25.dp)
|
||||
|
||||
@@ -41,24 +41,24 @@ fun UrlPreviewCard(
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
val url = URL(previewInfo.url)
|
||||
val validatedUrl = URL(previewInfo.url)
|
||||
|
||||
// correctly treating relative images
|
||||
val imageUrl = if (previewInfo.image.startsWith("/")) {
|
||||
URL(url, previewInfo.image).toString()
|
||||
URL(validatedUrl, previewInfo.image).toString()
|
||||
} else {
|
||||
previewInfo.image
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, url),
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = url.host,
|
||||
text = validatedUrl.host,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -1,64 +1,238 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VolumeOff
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import java.io.File
|
||||
|
||||
public var muted = mutableStateOf(true)
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
VideoView(localFile.toUri(), description, onDialog)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
VideoView(Uri.parse(videoUri), description, onDialog)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||
|
||||
val exoPlayer = remember(videoUri) {
|
||||
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
|
||||
|
||||
description?.let {
|
||||
mediaBuilder.setMediaMetadata(
|
||||
MediaMetadata.Builder().setDisplayTitle(it).build()
|
||||
)
|
||||
}
|
||||
|
||||
val media = mediaBuilder.build()
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
|
||||
)
|
||||
volume = 0f
|
||||
if (videoUri.scheme?.startsWith("file") == true) {
|
||||
setMediaItem(media)
|
||||
} else {
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
}
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
|
||||
val playerView = remember {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
innerOnDialog(it)
|
||||
LaunchedEffect(key1 = muted.value) {
|
||||
exoPlayer.volume = if (muted.value) 0f else 1f
|
||||
}
|
||||
|
||||
DisposableEffect(
|
||||
BoxWithConstraints() {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 70.dp)
|
||||
.align(Alignment.Center)
|
||||
.onVisibilityChanges { visible ->
|
||||
if (visible) {
|
||||
exoPlayer.play()
|
||||
} else {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
},
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
hideController()
|
||||
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MuteButton(muted, Modifier) {
|
||||
muted.value = !muted.value
|
||||
}
|
||||
}
|
||||
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
val lifecycle = lifecycleOwner.value.lifecycle
|
||||
lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
|
||||
val view = LocalView.current
|
||||
var isVisible: Boolean? by remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(isVisible) {
|
||||
onVisibilityChanges(isVisible == true)
|
||||
}
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
isVisible = coordinates.isCompletelyVisible(view)
|
||||
}
|
||||
}
|
||||
|
||||
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
|
||||
if (!isAttached) return false
|
||||
// Window relative bounds of our compose root view that are visible on the screen
|
||||
val globalRootRect = android.graphics.Rect()
|
||||
if (!view.getGlobalVisibleRect(globalRootRect)) {
|
||||
// we aren't visible at all.
|
||||
return false
|
||||
}
|
||||
val bounds = boundsInWindow()
|
||||
// Make sure we are completely in bounds.
|
||||
return bounds.top >= globalRootRect.top &&
|
||||
bounds.left >= globalRootRect.left &&
|
||||
bounds.right <= globalRootRect.right &&
|
||||
bounds.bottom <= globalRootRect.bottom
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.width(70.dp)
|
||||
.height(70.dp)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
|
||||
if (muted.value) {
|
||||
IconButton(
|
||||
onClick = toggle,
|
||||
modifier = Modifier.size(50.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VolumeOff,
|
||||
"Hash Verified",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = toggle,
|
||||
modifier = Modifier.size(50.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VolumeUp,
|
||||
"Hash Verified",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = {
|
||||
playerView
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.theme.Nip05
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
abstract class ZoomableContent(
|
||||
val description: String? = null,
|
||||
val dim: String? = null
|
||||
)
|
||||
|
||||
abstract class ZoomableUrlContent(
|
||||
val url: String,
|
||||
description: String? = null,
|
||||
val hash: String? = null,
|
||||
dim: String? = null,
|
||||
val uri: String? = null
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
class ZoomableUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
val blurhash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
class ZoomableUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
class ZoomableLocalVideo(
|
||||
localFile: File,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
dim: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
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: List<ZoomableContent> = listOf(content)) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var mainImageModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
|
||||
if (content is ZoomableUrlContent) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
|
||||
)
|
||||
} else if (content is ZoomablePreloadedContent) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
|
||||
)
|
||||
} else {
|
||||
mainImageModifier = mainImageModifier.clickable {
|
||||
dialogOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
when (content) {
|
||||
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
|
||||
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
|
||||
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
|
||||
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalImageView(
|
||||
content: ZoomableLocalImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
val ratio = aspectRatio(content.dim)
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
if (content.localFile.exists()) {
|
||||
AsyncImage(
|
||||
model = content.localFile,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Error || !content.localFile.exists()) {
|
||||
BlankNote()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UrlImageView(
|
||||
content: ZoomableUrlImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
val ratio = aspectRatio(content.dim)
|
||||
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Error) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun aspectRatio(dim: String?): Float? {
|
||||
if (dim == null) return null
|
||||
|
||||
val parts = dim.split("x")
|
||||
if (parts.size != 2) return null
|
||||
|
||||
return try {
|
||||
val width = parts[0].toFloat()
|
||||
val height = parts[1].toFloat()
|
||||
width / height
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
delay(200)
|
||||
cnt = content
|
||||
}
|
||||
}
|
||||
|
||||
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
if (content is ZoomableUrlContent) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
} else {
|
||||
Text("Loading content... ")
|
||||
}
|
||||
|
||||
val myId = "inlineContent"
|
||||
val emptytext = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("")
|
||||
appendInlineContent(myId, "[icon]")
|
||||
}
|
||||
}
|
||||
val inlineContent = mapOf(
|
||||
Pair(
|
||||
myId,
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Empty Text for Size of Icon
|
||||
Text(
|
||||
text = emptytext,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlurHash(
|
||||
blurhash: String?,
|
||||
description: String?,
|
||||
contentScale: ContentScale,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (blurhash == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
AsyncImage(
|
||||
model = BlurHashRequester.imageRequest(
|
||||
context,
|
||||
blurhash
|
||||
),
|
||||
contentDescription = description,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column() {
|
||||
val pagerState: PagerState = remember { PagerState() }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onDismiss)
|
||||
|
||||
val myContent = allImages[pagerState.currentPage]
|
||||
if (myContent is ZoomableUrlContent) {
|
||||
SaveToGallery(url = myContent.url)
|
||||
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
|
||||
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
itemsCount = allImages.size,
|
||||
itemContent = { index ->
|
||||
RenderImageOrVideo(allImages[index])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
RenderImageOrVideo(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
val mainModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState())
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
UrlImageView(content = content, mainImageModifier = mainModifier)
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.url, content.description)
|
||||
}
|
||||
} else if (content is ZoomableLocalImage) {
|
||||
LocalImageView(content = content, mainImageModifier = mainModifier)
|
||||
} else if (content is ZoomableLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.localFile, content.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
||||
val imageFile = snapshot.data.toFile()
|
||||
val bytes = imageFile.readBytes()
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(bytes).toHexKey()
|
||||
|
||||
Log.d("Image Hash Verification", "$hash == ${content.hash}")
|
||||
|
||||
return hash == content.hash
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
|
||||
if (verifiedHash == null) return
|
||||
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.width(40.dp)
|
||||
.height(40.dp)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
|
||||
if (verifiedHash == true) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
localContext,
|
||||
localContext.getString(R.string.hash_verification_passed),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"Hash Verified",
|
||||
tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
} else if (verifiedHash == false) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
localContext,
|
||||
localContext.getString(R.string.hash_verification_failed),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
tint = Color.Red,
|
||||
imageVector = Icons.Default.Report,
|
||||
contentDescription = "Invalid Hash",
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user