mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e05ab104b9 | ||
|
|
b4f1c4d13e | ||
|
|
939eb1bd8d | ||
|
|
854469cc7d | ||
|
|
338ae41baa | ||
|
|
6cae89d276 | ||
|
|
15c1e25122 | ||
|
|
c5716416b6 | ||
|
|
65e4a766ab | ||
|
|
c2a4c9bbb3 | ||
|
|
b042110154 | ||
|
|
da97b5593b | ||
|
|
44b4b0cdb9 | ||
|
|
190b7fe819 | ||
|
|
8eb89f0fe9 | ||
|
|
3085353729 | ||
|
|
1465ce112c | ||
|
|
5c797858ba | ||
|
|
8c51c4be0c | ||
|
|
4e8ecff67b | ||
|
|
83f8c51aca | ||
|
|
e675348461 | ||
|
|
9026f5bf07 | ||
|
|
02f18b5b35 | ||
|
|
db740f4747 | ||
|
|
6a3206b938 | ||
|
|
23414b0bc6 | ||
|
|
0234cfca30 | ||
|
|
f707714611 | ||
|
|
7b90230c45 | ||
|
|
d3468f3eba | ||
|
|
fc9500b45c | ||
|
|
df0b5fcd19 | ||
|
|
40e2dba490 | ||
|
|
d13e8bacec | ||
|
|
d3fa05a4df | ||
|
|
734dd2e119 | ||
|
|
a9ea9ea2ae | ||
|
|
1b521bc140 | ||
|
|
364e3c5f5f | ||
|
|
647cda3f90 | ||
|
|
bcca4d4bcc | ||
|
|
0b038dca17 | ||
|
|
ad6f63df56 | ||
|
|
b5b8b62a16 | ||
|
|
8d3066b377 | ||
|
|
18933b4326 | ||
|
|
672841c0ed | ||
|
|
872c7cb257 | ||
|
|
bfae5871c8 | ||
|
|
451c5d1648 | ||
|
|
3423626c60 | ||
|
|
8982f02a4a | ||
|
|
f06b5fdacf | ||
|
|
8ea8cf2985 | ||
|
|
2771d34951 | ||
|
|
669b33a3e1 | ||
|
|
c934a1cdfe | ||
|
|
0ccd41f5e8 | ||
|
|
1af3bb7dfc | ||
|
|
f0fcf1ea8a | ||
|
|
5325542c81 | ||
|
|
3d9675841b | ||
|
|
0d475fba0e | ||
|
|
05714de945 | ||
|
|
89c1b53c95 | ||
|
|
1c9041f12b | ||
|
|
d3de5e0a42 | ||
|
|
b1bd21533d | ||
|
|
51f997a34a | ||
|
|
d5607faadd | ||
|
|
1a9c3ccb79 | ||
|
|
053de4d06f | ||
|
|
4016458540 | ||
|
|
8b98efaad7 | ||
|
|
217ed90d9a | ||
|
|
b82aefbd5d | ||
|
|
63b954d38d | ||
|
|
76cdea0087 | ||
|
|
da1a3a8f85 | ||
|
|
d11e92e166 | ||
|
|
61efd237c2 | ||
|
|
6099948605 | ||
|
|
45b743e7c8 | ||
|
|
108c6cbeb4 | ||
|
|
da02891c6a | ||
|
|
a03e9ea946 | ||
|
|
dd4a6f8da0 | ||
|
|
c01033cbb0 | ||
|
|
1fe93395c5 | ||
|
|
cf960fe59d | ||
|
|
2416d41131 | ||
|
|
0699095d77 | ||
|
|
6ed3e84d95 | ||
|
|
6c196cfeb7 | ||
|
|
987784fb89 | ||
|
|
d60ac5d73b | ||
|
|
c2361c034c | ||
|
|
199514e7f3 | ||
|
|
a1364d19c2 | ||
|
|
e73428b156 | ||
|
|
a684a222cb | ||
|
|
1646cbbd68 | ||
|
|
035a48a0cd | ||
|
|
71be8ce239 | ||
|
|
1ccf9bf467 | ||
|
|
42a0cd0b1b | ||
|
|
39547b1d11 | ||
|
|
c69969947b | ||
|
|
e1738a25a1 | ||
|
|
6c54082a12 | ||
|
|
bc5500731d | ||
|
|
0665c552aa | ||
|
|
1ab8463578 | ||
|
|
e016e68e42 | ||
|
|
d116159ed9 | ||
|
|
3c24c011e3 | ||
|
|
536bdfe43a | ||
|
|
0b41110938 | ||
|
|
da1d4018cf | ||
|
|
b569c67d3b | ||
|
|
50a5a68b71 | ||
|
|
23a4d80ddb | ||
|
|
6a64ebcc5b | ||
|
|
8e622bfbb1 | ||
|
|
d189dce7f0 | ||
|
|
43ac1bf555 | ||
|
|
d2d26e4e57 | ||
|
|
de1f13d64c | ||
|
|
2be04bfbe2 | ||
|
|
57c33ae06a | ||
|
|
99e545f0b8 | ||
|
|
accb115a20 | ||
|
|
9de1cec160 | ||
|
|
f7774700db | ||
|
|
ffb81c6246 | ||
|
|
b7c9b14b1f | ||
|
|
53b0db61ea | ||
|
|
6c09e47e4f | ||
|
|
9d9ad63b4d | ||
|
|
7ef46a2662 | ||
|
|
a7d8c87c47 | ||
|
|
1bb2459277 | ||
|
|
6b39562925 | ||
|
|
3d34e1fc4e | ||
|
|
469e1241d5 | ||
|
|
86c0ca975b | ||
|
|
53eeea3225 | ||
|
|
e9ff9d3b37 | ||
|
|
e2c011ec60 | ||
|
|
5328740bff | ||
|
|
017df9845d | ||
|
|
ed70658be9 | ||
|
|
dc74c3014f | ||
|
|
53d5c5f354 | ||
|
|
63ab2a9fbe | ||
|
|
30391160da | ||
|
|
3273aa32a5 | ||
|
|
1e358d31d5 | ||
|
|
b57dd98059 | ||
|
|
5b596d2a56 | ||
|
|
62db68c7b2 | ||
|
|
b3bb85cd53 | ||
|
|
fe6fb8470d | ||
|
|
ee97eac00c | ||
|
|
48e3ccc165 | ||
|
|
52c0503b6d | ||
|
|
c052399a16 | ||
|
|
76faf7ae35 | ||
|
|
36ea33a919 | ||
|
|
8aad4d8030 | ||
|
|
a23c8c5c04 | ||
|
|
0434ef6ec6 | ||
|
|
4d85a2f6fb | ||
|
|
e9b235f748 | ||
|
|
30320805da | ||
|
|
8e913ef974 | ||
|
|
eb191406e6 | ||
|
|
ca89b70150 | ||
|
|
6bcd6950ef | ||
|
|
dfa9c77e3e | ||
|
|
af143f2ea1 | ||
|
|
52761b3901 | ||
|
|
f027d23fcf | ||
|
|
2d30f3e9d2 | ||
|
|
a63b383425 | ||
|
|
e04eb733e7 | ||
|
|
0a49298ec2 | ||
|
|
1121cfcaf9 | ||
|
|
66b5fad6c2 | ||
|
|
5d352f1b6c | ||
|
|
9018175f54 | ||
|
|
98c55661e8 | ||
|
|
521407f879 | ||
|
|
fa4257ad7d | ||
|
|
d73dec8f0b | ||
|
|
c6124376df | ||
|
|
87c19f4232 | ||
|
|
a1f116909f | ||
|
|
ca9951f9b8 | ||
|
|
2bedd79e6f | ||
|
|
a9af2d8982 | ||
|
|
655a563f61 | ||
|
|
9777c709a1 | ||
|
|
251f4854fc | ||
|
|
43c32f0b0e | ||
|
|
ec3b07147c | ||
|
|
0e6a2c339e | ||
|
|
844f7cd478 | ||
|
|
a2edc9f3b2 | ||
|
|
35d09aee77 | ||
|
|
a79d1d5bd6 | ||
|
|
ba9315fcd1 | ||
|
|
f5c71b4f90 | ||
|
|
5a40b39952 | ||
|
|
2ac3b42d4d | ||
|
|
7cfad373f2 | ||
|
|
5d061a79e2 | ||
|
|
eb59a9d505 | ||
|
|
784f867a8e | ||
|
|
954a9d66fb | ||
|
|
dfdced3e8c | ||
|
|
0af141c5b7 | ||
|
|
70c4c69bbf | ||
|
|
5597189ea5 | ||
|
|
8884adcefb |
44
.github/workflows/create-release.yml
vendored
44
.github/workflows/create-release.yml
vendored
@@ -112,6 +112,28 @@ jobs:
|
||||
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Play APK arm64-v8a Asset
|
||||
id: upload-release-asset-play-arm64-v8a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Play APK armeabi-v7a Asset
|
||||
id: upload-release-asset-play-armeabi-v7a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
# F-Droid APK
|
||||
- name: Upload F-Droid APK Universal Asset
|
||||
id: upload-release-asset-fdroid-universal-apk
|
||||
@@ -146,6 +168,28 @@ jobs:
|
||||
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload F-Droid APK arm64-v8a Asset
|
||||
id: upload-release-asset-fdroid-arm64-v8a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload F-Droid APK armeabi-v7a Asset
|
||||
id: upload-release-asset-fdroid-armeabi-v7a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
|
||||
|
||||
# Google Play AAB
|
||||
|
||||
30
.github/workflows/crowdin.yml
vendored
Normal file
30
.github/workflows/crowdin.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Crowdin Action
|
||||
|
||||
on:
|
||||
push:
|
||||
# paths: ["app/src/main/res/**/strings.xml"] // removes filter to allow downloads at any moment.
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: 'New Crowdin Translations'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'main'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
@@ -82,13 +82,14 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Private Messages and Small Groups (NIP-24)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Status Event (NIP-38)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Workspaces
|
||||
- [ ] Expiration Support (NIP-40)
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
|
||||
@@ -13,14 +13,14 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 273
|
||||
versionName "0.74.0"
|
||||
versionCode 294
|
||||
versionName "0.76.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW', 'ja']
|
||||
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'in', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -54,7 +54,7 @@ android {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "x86", "x86_64"
|
||||
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation project(path: ':quartz')
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation "androidx.core:core-ktx:$core_ktx_version"
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
@@ -111,7 +111,8 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
implementation "net.engawapg.lib:zoomable:1.4.3"
|
||||
// Zoomable images
|
||||
implementation "net.engawapg.lib:zoomable:1.5.0"
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
@@ -119,7 +120,7 @@ dependencies {
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// link preview
|
||||
// HTML Parsing for Link Preview
|
||||
implementation 'org.jsoup:jsoup:1.16.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
@@ -159,9 +160,9 @@ dependencies {
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
|
||||
implementation 'androidx.media3:media3-ui:1.1.0'
|
||||
implementation 'androidx.media3:media3-session:1.1.0'
|
||||
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-session:$media3_version"
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
@@ -170,7 +171,7 @@ dependencies {
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.2.2')
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.2.3')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
// Charts
|
||||
|
||||
@@ -21,7 +21,7 @@ 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=="
|
||||
|
||||
fun testBase(server: FileServer) {
|
||||
private fun testBase(server: FileServer) {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
@@ -39,7 +39,7 @@ class ImageUploadTesting {
|
||||
"image/gif",
|
||||
server,
|
||||
onSuccess = { newUrl, contentType ->
|
||||
println("Uploaded to $url")
|
||||
println("Uploaded $contentType to $url")
|
||||
url = newUrl
|
||||
countDownLatch.countDown()
|
||||
},
|
||||
|
||||
@@ -4128,7 +4128,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
}
|
||||
|
||||
private fun printStateForDebug(state: RichTextViewerState) {
|
||||
state.paragraphs.forEachIndexed { index, paragraph ->
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
paragraph.words.forEach { seg ->
|
||||
println(
|
||||
"\"${
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import junit.framework.TestCase
|
||||
@@ -95,7 +97,7 @@ class ThreadAssemblerTest {
|
||||
|
||||
val naddr = ATag(30023, "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", "1680612926599", null)
|
||||
|
||||
val filter = ThreadFeedFilter(naddr.toTag())
|
||||
val filter = ThreadFeedFilter(Account(KeyPair()), naddr.toTag())
|
||||
val calculatedFeed = filter.feed()
|
||||
|
||||
val expecteedOrder = listOf(
|
||||
@@ -126,14 +128,14 @@ class ThreadAssemblerTest {
|
||||
"6a58f8315af5badb1bdaeb5489417b94621a4d8e192ae2fedcca0c5dcf0c9cd4",
|
||||
"6e9bb03c7c40d67fec0d0bb872548ec207ba0ac4533efa137d7bcaca9fb4b191",
|
||||
"e2ae784b239cac4bad38136e4bd758b87dd261b659ef460450064bf9073edcb3",
|
||||
"45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7",
|
||||
"7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3",
|
||||
"674c62f84afdc045bc3623ea132d90afdfe4b64249807f65302231115af5406d",
|
||||
"d54761f672669ea4f4b7592f3b0a30ee28de340b0a7e46b91af94e66905171c9",
|
||||
"00813a18ac9084cd0948c27027a980e34039a3011f30279a8b52ad87da5a3031",
|
||||
"87a5bd25aa084cefb3357fc9c2a5b327254fab35fdd7b2d4bd0acddc63d0abe8",
|
||||
"512962dbada5fd5015fc727a107d5c3f569662de67eab8e5da5a8065012cf11e",
|
||||
"ce6e32e3e17b6901d2cc70b60f3743e24f885bb6e9da6d88cff516079eac1883",
|
||||
"7a4a2419824669f07081abe2132f8cc0027efbce066ccdf187c897bb7ffa5dc3",
|
||||
"45d4fc726f2cc5b524be862c14fdadc1a24b25b8c6c011eedf2d2909589263e7",
|
||||
"d4a0b4f08d98d82a04292654ec132723cc2cf3fa24ffb6c0833426cb9372f4d5",
|
||||
"8cdc4676aca93bbafcfbe6784f9b2df54e8ca20fbe69ba55fda487736bfdb7f6",
|
||||
"7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26"
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
tools:targetApi="33">
|
||||
tools:targetApi="34">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -205,6 +205,10 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
fun allLocalAccountNPubs(): Set<String> {
|
||||
return savedAccounts().toSet()
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
prefs.edit().apply {
|
||||
@@ -299,7 +303,8 @@ object LocalPreferences {
|
||||
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
|
||||
Event.mapper.readValue<Set<RelaySetupInfo>>(it)
|
||||
println("LocalRelays: $it")
|
||||
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
|
||||
} ?: setOf<RelaySetupInfo>()
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
@@ -310,24 +315,24 @@ object LocalPreferences {
|
||||
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
|
||||
Event.mapper.readValue<List<Long>>(it)
|
||||
Event.mapper.readValue<List<Long>?>(it)
|
||||
}?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
|
||||
Event.mapper.readValue<List<String>>(it)
|
||||
Event.mapper.readValue<List<String>?>(it)
|
||||
}?.ifEmpty { listOf("+") } ?: listOf("+")
|
||||
|
||||
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
|
||||
LnZapEvent.ZapType.values().first { it.name == serverName }
|
||||
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
ServersAvailable.values().first { it.name == serverName }
|
||||
ServersAvailable.values().firstOrNull() { it.name == serverName }
|
||||
} ?: ServersAvailable.NOSTR_BUILD
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
Event.mapper.readValue<Nip47URI>(it)
|
||||
Event.mapper.readValue<Nip47URI?>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
@@ -345,7 +350,7 @@ object LocalPreferences {
|
||||
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
Event.mapper.readValue<Map<String, String>>(it)
|
||||
Event.mapper.readValue<Map<String, String>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
@@ -369,7 +374,7 @@ object LocalPreferences {
|
||||
|
||||
val lastReadPerRoute = try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
Event.mapper.readValue<Map<String, Long>>(it)
|
||||
Event.mapper.readValue<Map<String, Long>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -26,15 +26,35 @@ class PlaybackService : MediaSessionService() {
|
||||
return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
fun lazyHlsDS(): MultiPlayerPlaybackManager {
|
||||
managerHls?.let { return it }
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerHls = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
|
||||
managerProgressive?.let { return it }
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
fun lazyLocalDS(): MultiPlayerPlaybackManager {
|
||||
managerLocal?.let { return it }
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
|
||||
managerLocal = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
// Create your Player and MediaSession in the onCreate lifecycle event
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
}
|
||||
@@ -62,11 +82,11 @@ class PlaybackService : MediaSessionService() {
|
||||
|
||||
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
|
||||
return if (fileName.startsWith("file")) {
|
||||
managerLocal
|
||||
lazyLocalDS()
|
||||
} else if (fileName.endsWith("m3u8")) {
|
||||
managerHls
|
||||
lazyHlsDS()
|
||||
} else {
|
||||
managerProgressive
|
||||
lazyProgressiveDS()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import java.io.File
|
||||
|
||||
object ServiceManager {
|
||||
@@ -114,14 +115,22 @@ object ServiceManager {
|
||||
Client.disconnect()
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
fun cleanObservers() {
|
||||
LocalCache.cleanObservers()
|
||||
}
|
||||
|
||||
fun trimMemory() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
LocalCache.pruneRepliesAndReactions(it)
|
||||
LocalCache.pruneContactLists(accounts)
|
||||
LocalCache.pruneRepliesAndReactions(accounts)
|
||||
LocalCache.prunePastVersionsOfReplaceables()
|
||||
LocalCache.pruneExpiredEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ fun getLanguagesSpokenByUser(): Set<String> {
|
||||
return codedList
|
||||
}
|
||||
|
||||
val GLOBAL_FOLLOWS = " Global "
|
||||
val KIND3_FOLLOWS = " All Follows "
|
||||
val GLOBAL_FOLLOWS = " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val KIND3_FOLLOWS = " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Stable
|
||||
@@ -101,14 +101,16 @@ class Account(
|
||||
val showSensitiveContent: Boolean?
|
||||
)
|
||||
|
||||
val liveHiddenUsers: LiveData<LiveHiddenUsers> = live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent ->
|
||||
val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(keyPair.privKey)
|
||||
LiveHiddenUsers(
|
||||
hiddenUsers = liveBlockedUsers ?: persistentSetOf(),
|
||||
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
|
||||
showSensitiveContent = showSensitiveContent
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
val liveHiddenUsers: LiveData<LiveHiddenUsers> by lazy {
|
||||
live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent ->
|
||||
val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(keyPair.privKey)
|
||||
LiveHiddenUsers(
|
||||
hiddenUsers = liveBlockedUsers ?: persistentSetOf(),
|
||||
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
|
||||
showSensitiveContent = showSensitiveContent
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
var userProfileCache: User? = null
|
||||
|
||||
@@ -452,7 +454,13 @@ class Account(
|
||||
|
||||
fun broadcast(note: Note) {
|
||||
note.event?.let {
|
||||
Client.send(it)
|
||||
if (it is WrappedEvent && it.host != null) {
|
||||
it.host?.let { hostEvent ->
|
||||
Client.send(hostEvent)
|
||||
}
|
||||
} else {
|
||||
Client.send(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,6 +792,23 @@ class Account(
|
||||
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent)
|
||||
|
||||
// broadcast replied notes
|
||||
replyingTo?.let {
|
||||
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
replyTo?.forEach {
|
||||
it.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPoll(
|
||||
@@ -826,6 +851,17 @@ class Account(
|
||||
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent)
|
||||
|
||||
replyTo?.forEach {
|
||||
it.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
|
||||
@@ -974,6 +1010,40 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStatus(oldStatus: AddressableNote, newStatus: String) {
|
||||
if (!isWriteable()) return
|
||||
val oldEvent = oldStatus.event as? StatusEvent ?: return
|
||||
|
||||
val event = StatusEvent.update(oldEvent, newStatus, keyPair.privKey!!)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event, null)
|
||||
}
|
||||
|
||||
fun createStatus(newStatus: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val event = StatusEvent.create(newStatus, "general", expiration = null, keyPair.privKey!!)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event, null)
|
||||
}
|
||||
|
||||
fun deleteStatus(oldStatus: AddressableNote) {
|
||||
if (!isWriteable()) return
|
||||
val oldEvent = oldStatus.event as? StatusEvent ?: return
|
||||
|
||||
val event = StatusEvent.clear(oldEvent, keyPair.privKey!!)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event, null)
|
||||
|
||||
val event2 = DeletionEvent.create(listOf(event.id), keyPair.privKey!!)
|
||||
|
||||
Client.send(event2)
|
||||
LocalCache.consume(event2)
|
||||
}
|
||||
|
||||
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1591,14 +1661,18 @@ class Account(
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) {
|
||||
usersRelayList = usersRelayList + Relay(
|
||||
Constants.forcedRelayForSearch.url,
|
||||
Constants.forcedRelayForSearch.read,
|
||||
Constants.forcedRelayForSearch.write,
|
||||
Constants.forcedRelayForSearch.feedTypes,
|
||||
proxy
|
||||
)
|
||||
val searchRelays = usersRelayList.filter { it.url.removeSuffix("/") in Constants.forcedRelaysForSearchSet }
|
||||
val hasSearchRelay = usersRelayList.any { it.activeTypes.contains(FeedType.SEARCH) }
|
||||
if (!hasSearchRelay && searchRelays.isEmpty()) {
|
||||
usersRelayList = usersRelayList + Constants.forcedRelayForSearch.map {
|
||||
Relay(
|
||||
it.url,
|
||||
it.read,
|
||||
it.write,
|
||||
it.feedTypes,
|
||||
proxy
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return usersRelayList.toTypedArray()
|
||||
@@ -1610,12 +1684,16 @@ class Account(
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
fun convertGlobalRelays(): Array<String> {
|
||||
return localRelays.filter { it.feedTypes.contains(FeedType.GLOBAL) }
|
||||
fun activeGlobalRelays(): Array<String> {
|
||||
return (activeRelays() ?: convertLocalRelays()).filter { it.activeTypes.contains(FeedType.GLOBAL) }
|
||||
.map { it.url }
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
fun activeWriteRelays(): List<Relay> {
|
||||
return (activeRelays() ?: convertLocalRelays()).filter { it.write }
|
||||
}
|
||||
|
||||
fun reconnectIfRelaysHaveChanged() {
|
||||
val newRelaySet = activeRelays() ?: convertLocalRelays()
|
||||
if (!Client.isSameRelaySetConfig(newRelaySet)) {
|
||||
|
||||
@@ -164,20 +164,12 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
}
|
||||
NostrSingleChannelDataSource.add(channel)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
}
|
||||
NostrSingleChannelDataSource.remove(channel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Stable
|
||||
class Chatroom() {
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
var subject: String? = null
|
||||
var subjectCreatedAt: Long? = null
|
||||
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
val newSubject = msg.event?.subject()
|
||||
|
||||
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
|
||||
subject = newSubject
|
||||
subjectCreatedAt = msg.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
|
||||
subject = it.event?.subject()
|
||||
subjectCreatedAt = it.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean {
|
||||
return roomMessages.any { it.author?.pubkeyHex in keySet }
|
||||
}
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
|
||||
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
|
||||
// Recent messages, keep last 100
|
||||
sorted.take(100).toSet()
|
||||
} else {
|
||||
// Old messages, keep the last one.
|
||||
sorted.take(1).toSet()
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false }
|
||||
|
||||
val toRemove = roomMessages.minus(toKeep)
|
||||
roomMessages = toKeep
|
||||
return toRemove
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -204,46 +206,6 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PeopleListEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: AdvertisedRelayListEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// 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"))
|
||||
@@ -349,25 +311,6 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
@@ -392,102 +335,16 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: EmojiPackSelectionEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: EmojiPackEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: ClassifiedsEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: PinListEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: RelaySetEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: AudioTrackEvent) {
|
||||
fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: EmojiPackSelectionEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: EmojiPackEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: ClassifiedsEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: StatusEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -503,30 +360,14 @@ object LocalCache {
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList<Note>())
|
||||
author.liveSet?.innerStatuses?.invalidateData()
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
|
||||
|
||||
fun consume(event: BadgeProfilesEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
@@ -541,7 +382,7 @@ object LocalCache {
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
@@ -571,7 +412,14 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun comsume(event: NNSEvent) {
|
||||
private fun comsume(event: NNSEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: AppDefinitionEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarDateSlotEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarTimeSlotEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarRSVPEvent) { consumeBaseReplaceable(event) }
|
||||
|
||||
private fun consumeBaseReplaceable(event: BaseAddressableEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -591,45 +439,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: AppDefinitionEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// 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 consume(event: AppRecommendationEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
// 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 consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: RecommendRelayEvent) {
|
||||
@@ -729,7 +539,7 @@ object LocalCache {
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
@@ -751,7 +561,7 @@ object LocalCache {
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
@@ -840,7 +650,7 @@ object LocalCache {
|
||||
|
||||
mentions.forEach {
|
||||
// doesn't add to reports, but triggers recounts
|
||||
it.liveSet?.reports?.invalidateData()
|
||||
it.liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,7 +1111,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
return notes.values.filter {
|
||||
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) &&
|
||||
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) &&
|
||||
(
|
||||
it.event?.content()?.contains(text, true) ?: false ||
|
||||
it.event?.matchTag1With(text) ?: false ||
|
||||
@@ -1309,7 +1119,7 @@ object LocalCache {
|
||||
it.idNote().startsWith(text, true)
|
||||
)
|
||||
} + addressables.values.filter {
|
||||
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) &&
|
||||
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) &&
|
||||
(
|
||||
it.event?.content()?.contains(text, true) ?: false ||
|
||||
it.event?.matchTag1With(text) ?: false ||
|
||||
@@ -1338,6 +1148,18 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
|
||||
checkNotInMainThread()
|
||||
|
||||
return addressables.filter {
|
||||
val noteEvent = it.value.event
|
||||
(noteEvent is StatusEvent && noteEvent.pubKey == user.pubkeyHex && !noteEvent.isExpired() && noteEvent.content.isNotBlank())
|
||||
}.values
|
||||
.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
fun cleanObservers() {
|
||||
notes.forEach {
|
||||
it.value.clearLive()
|
||||
@@ -1361,13 +1183,7 @@ object LocalCache {
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.idHex)
|
||||
// Doesn't need to clean up the replies and mentions.. Too small to matter.
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { _ ->
|
||||
it.removeReply(it)
|
||||
}
|
||||
removeFromCache(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
@@ -1386,12 +1202,7 @@ object LocalCache {
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.idHex)
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { _ ->
|
||||
it.removeReply(it)
|
||||
}
|
||||
removeFromCache(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
@@ -1405,9 +1216,37 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneRepliesAndReactions(account: Account) {
|
||||
fun prunePastVersionsOfReplaceables() {
|
||||
val toBeRemoved = notes.filter {
|
||||
val noteEvent = it.value.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
noteEvent.createdAt() < (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}.values
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()]
|
||||
if (newerVersion != null) {
|
||||
it.moveAllReferencesTo(newerVersion)
|
||||
}
|
||||
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
|
||||
checkNotInMainThread()
|
||||
val user = account.userProfile()
|
||||
|
||||
val toBeRemoved = notes.filter {
|
||||
(
|
||||
@@ -1415,27 +1254,113 @@ object LocalCache {
|
||||
it.value.event is ReactionEvent || it.value.event is LnZapEvent || it.value.event is LnZapRequestEvent ||
|
||||
it.value.event is ReportEvent || it.value.event is GenericRepostEvent
|
||||
) &&
|
||||
it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
|
||||
it.value.liveSet?.isInUse() != true && // don't delete if observing.
|
||||
it.value.author != user && // don't delete if it is the logged in account
|
||||
it.value.event?.isTaggedUser(user.pubkeyHex) != true // don't delete if it's a notification to the logged in user
|
||||
it.value.author?.pubkeyHex !in accounts && // don't delete if it is the logged in account
|
||||
it.value.event?.isTaggedUsers(accounts) != true // don't delete if it's a notification to the logged in user
|
||||
}.values
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.idHex)
|
||||
|
||||
it.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeReply(it)
|
||||
masterNote.removeBoost(it)
|
||||
masterNote.removeReaction(it)
|
||||
masterNote.removeZap(it)
|
||||
masterNote.clearEOSE() // allows reloading of these events
|
||||
}
|
||||
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
|
||||
toBeRemoved.forEach {
|
||||
it.replyTo?.forEach { masterNote ->
|
||||
masterNote.clearEOSE() // allows reloading of these events
|
||||
}
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeFromCache(note: Note) {
|
||||
note.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeReply(note)
|
||||
masterNote.removeBoost(note)
|
||||
masterNote.removeReaction(note)
|
||||
masterNote.removeZap(note)
|
||||
masterNote.removeReport(note)
|
||||
masterNote.clearEOSE() // allows reloading of these events if needed
|
||||
}
|
||||
|
||||
if (note.event is LnZapEvent) {
|
||||
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it)
|
||||
author?.removeZap(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
if (note.event is LnZapRequestEvent) {
|
||||
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it)
|
||||
author?.removeZap(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
if (note.event is ReportEvent) {
|
||||
(note.event as ReportEvent).reportedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it.key)
|
||||
author?.removeReport(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
fun removeAuthorLinkTo(note: Note) {
|
||||
if (note.event is LnZapEvent) {
|
||||
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it)
|
||||
author?.removeZap(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
if (note.event is LnZapRequestEvent) {
|
||||
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it)
|
||||
author?.removeZap(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
if (note.event is ReportEvent) {
|
||||
(note.event as ReportEvent).reportedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it.key)
|
||||
author?.removeReport(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
|
||||
nextToBeRemoved.forEach { note ->
|
||||
removeFromCache(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneExpiredEvents() {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved = notes.filter {
|
||||
it.value.event?.isExpired() == true
|
||||
}.values
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
|
||||
}
|
||||
@@ -1457,17 +1382,7 @@ object LocalCache {
|
||||
}.flatten()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeReply(it)
|
||||
masterNote.removeBoost(it)
|
||||
masterNote.removeReaction(it)
|
||||
masterNote.removeZap(it)
|
||||
masterNote.removeReport(it)
|
||||
}
|
||||
|
||||
notes.remove(it.idHex)
|
||||
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
@@ -1476,27 +1391,15 @@ object LocalCache {
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
|
||||
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
|
||||
nextToBeRemoved.forEach { note ->
|
||||
if (note.event is LnZapEvent) {
|
||||
(note.event as LnZapEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
|
||||
}
|
||||
if (note.event is LnZapRequestEvent) {
|
||||
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
|
||||
}
|
||||
if (note.event is ReportEvent) {
|
||||
(note.event as ReportEvent).reportedAuthor().mapNotNull { getUserIfExists(it.key)?.removeZap(note) }
|
||||
}
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneContactLists(userAccount: Account) {
|
||||
fun pruneContactLists(loggedIn: Set<HexKey>) {
|
||||
checkNotInMainThread()
|
||||
|
||||
var removingContactList = 0
|
||||
users.values.forEach {
|
||||
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
|
||||
if (it.pubkeyHex !in loggedIn &&
|
||||
(it.liveSet == null || it.liveSet?.isInUse() == false) &&
|
||||
it.latestContactList != null
|
||||
) {
|
||||
it.latestContactList = null
|
||||
removingContactList++
|
||||
}
|
||||
@@ -1547,6 +1450,10 @@ object LocalCache {
|
||||
is BadgeDefinitionEvent -> consume(event)
|
||||
is BadgeProfilesEvent -> consume(event)
|
||||
is BookmarkListEvent -> consume(event)
|
||||
is CalendarEvent -> consume(event)
|
||||
is CalendarDateSlotEvent -> consume(event)
|
||||
is CalendarTimeSlotEvent -> consume(event)
|
||||
is CalendarRSVPEvent -> consume(event)
|
||||
is ChannelCreateEvent -> consume(event)
|
||||
is ChannelHideMessageEvent -> consume(event)
|
||||
is ChannelMessageEvent -> consume(event, relay)
|
||||
@@ -1576,9 +1483,10 @@ object LocalCache {
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.zapRequest?.let {
|
||||
// must have a valid request
|
||||
verifyAndConsume(it, relay)
|
||||
consume(event)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> consume(event)
|
||||
is LnZapPaymentRequestEvent -> consume(event)
|
||||
@@ -1589,6 +1497,7 @@ object LocalCache {
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event)
|
||||
is PeopleListEvent -> consume(event)
|
||||
is PollNoteEvent -> consume(event, relay)
|
||||
is ReactionEvent -> consume(event)
|
||||
is RecommendRelayEvent -> consume(event)
|
||||
is RelaySetEvent -> consume(event)
|
||||
@@ -1605,8 +1514,9 @@ object LocalCache {
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is StatusEvent -> consume(event, relay)
|
||||
is TextNoteEvent -> consume(event, relay)
|
||||
is PollNoteEvent -> consume(event, relay)
|
||||
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
|
||||
@@ -10,6 +12,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.combineWith
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
@@ -19,6 +22,7 @@ import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
@@ -72,7 +76,22 @@ open class Note(val idHex: String) {
|
||||
open fun idNote() = id().toNote()
|
||||
|
||||
open fun toNEvent(): String {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
val myEvent = event
|
||||
return if (myEvent is WrappedEvent) {
|
||||
val host = myEvent.host
|
||||
if (host != null) {
|
||||
Nip19.createNEvent(
|
||||
host.id,
|
||||
host.pubKey,
|
||||
host.kind(),
|
||||
relays.firstOrNull()
|
||||
)
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
fun toNostrUri(): String {
|
||||
@@ -108,7 +127,7 @@ open class Note(val idHex: String) {
|
||||
this.author = author
|
||||
this.replyTo = replyTo
|
||||
|
||||
liveSet?.metadata?.invalidateData()
|
||||
liveSet?.innerMetadata?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,24 +136,60 @@ open class Note(val idHex: String) {
|
||||
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
|
||||
}
|
||||
|
||||
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
|
||||
|
||||
/**
|
||||
* This method caches signatures during each execution to avoid recalculation in longer threads
|
||||
*/
|
||||
fun replyLevelSignature(
|
||||
eventsToConsider: Set<Note>,
|
||||
cachedSignatures: MutableMap<Note, String>
|
||||
): String {
|
||||
eventsToConsider: Set<HexKey>,
|
||||
cachedSignatures: MutableMap<Note, LevelSignature>,
|
||||
account: User,
|
||||
accountFollowingSet: Set<String>,
|
||||
now: Long
|
||||
): LevelSignature {
|
||||
val replyTo = replyTo
|
||||
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
|
||||
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
return LevelSignature(
|
||||
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
|
||||
createdAt = createdAt(),
|
||||
author = author
|
||||
)
|
||||
}
|
||||
|
||||
val mySignature = replyTo
|
||||
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
val parent = (
|
||||
replyTo
|
||||
.filter { it.idHex in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(
|
||||
eventsToConsider,
|
||||
cachedSignatures,
|
||||
account,
|
||||
accountFollowingSet,
|
||||
now
|
||||
).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxByOrNull { it.signature.length }
|
||||
)
|
||||
|
||||
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
|
||||
|
||||
val threadOrder = if (parent?.author == author && createdAt() != null) {
|
||||
// author of the thread first, in **ascending** order
|
||||
"9" + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + idHex.substring(0, 8)
|
||||
} else if (author?.pubkeyHex == account.pubkeyHex) {
|
||||
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
|
||||
} else if (author?.pubkeyHex in accountFollowingSet) {
|
||||
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
|
||||
} else {
|
||||
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
|
||||
}
|
||||
|
||||
val mySignature = LevelSignature(
|
||||
signature = parentSignature + "/" + threadOrder + ";",
|
||||
createdAt = createdAt(),
|
||||
author = author
|
||||
)
|
||||
|
||||
cachedSignatures[this] = mySignature
|
||||
return mySignature
|
||||
@@ -154,21 +209,21 @@ open class Note(val idHex: String) {
|
||||
fun addReply(note: Note) {
|
||||
if (note !in replies) {
|
||||
replies = replies + note
|
||||
liveSet?.replies?.invalidateData()
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReply(note: Note) {
|
||||
if (note in replies) {
|
||||
replies = replies - note
|
||||
liveSet?.replies?.invalidateData()
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBoost(note: Note) {
|
||||
if (note in boosts) {
|
||||
boosts = boosts - note
|
||||
liveSet?.boosts?.invalidateData()
|
||||
liveSet?.innerBoosts?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,11 +246,11 @@ open class Note(val idHex: String) {
|
||||
relays = listOf<String>()
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
|
||||
liveSet?.replies?.invalidateData()
|
||||
liveSet?.reactions?.invalidateData()
|
||||
liveSet?.boosts?.invalidateData()
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
liveSet?.innerBoosts?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
|
||||
return toBeRemoved
|
||||
}
|
||||
@@ -214,7 +269,7 @@ open class Note(val idHex: String) {
|
||||
reactions = reactions + Pair(reaction, newList)
|
||||
}
|
||||
|
||||
liveSet?.reactions?.invalidateData()
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,7 +281,7 @@ open class Note(val idHex: String) {
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,29 +289,28 @@ open class Note(val idHex: String) {
|
||||
fun removeZap(note: Note) {
|
||||
if (zaps[note] != null) {
|
||||
zaps = zaps.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zaps.containsValue(note)) {
|
||||
val toRemove = zaps.filterValues { it == note }
|
||||
zaps = zaps.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
zaps = zaps.filterValues { it != note }
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
liveSet?.boosts?.invalidateData()
|
||||
liveSet?.innerBoosts?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,12 +332,12 @@ open class Note(val idHex: String) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,12 +360,12 @@ open class Note(val idHex: String) {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,10 +376,10 @@ open class Note(val idHex: String) {
|
||||
|
||||
if (reaction !in reactions.keys) {
|
||||
reactions = reactions + Pair(reaction, listOf(note))
|
||||
liveSet?.reactions?.invalidateData()
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
} else if (reactions[reaction]?.contains(note) == false) {
|
||||
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
|
||||
liveSet?.reactions?.invalidateData()
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,17 +388,17 @@ open class Note(val idHex: String) {
|
||||
|
||||
if (author !in reports.keys) {
|
||||
reports = reports + Pair(author, listOf(note))
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Relay) {
|
||||
if (relay.url !in relays) {
|
||||
relays = relays + relay.url
|
||||
liveSet?.relays?.invalidateData()
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,10 +498,6 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun countReactions(): Int {
|
||||
return reactions.values.sumOf { it.size }
|
||||
}
|
||||
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
@@ -613,6 +663,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
@@ -634,16 +685,60 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteLiveSet(u: Note) {
|
||||
// Observers line up here.
|
||||
val metadata: NoteLiveData = NoteLiveData(u)
|
||||
val innerMetadata = NoteBundledRefresherLiveData(u)
|
||||
val innerReactions = NoteBundledRefresherLiveData(u)
|
||||
val innerBoosts = NoteBundledRefresherLiveData(u)
|
||||
val innerReplies = NoteBundledRefresherLiveData(u)
|
||||
val innerReports = NoteBundledRefresherLiveData(u)
|
||||
val innerRelays = NoteBundledRefresherLiveData(u)
|
||||
val innerZaps = NoteBundledRefresherLiveData(u)
|
||||
|
||||
val reactions: NoteLiveData = NoteLiveData(u)
|
||||
val boosts: NoteLiveData = NoteLiveData(u)
|
||||
val replies: NoteLiveData = NoteLiveData(u)
|
||||
val reports: NoteLiveData = NoteLiveData(u)
|
||||
val relays: NoteLiveData = NoteLiveData(u)
|
||||
val zaps: NoteLiveData = NoteLiveData(u)
|
||||
val metadata = innerMetadata.map { it }
|
||||
val reactions = innerReactions.map { it }
|
||||
val boosts = innerBoosts.map { it }
|
||||
val replies = innerReplies.map { it }
|
||||
val reports = innerReports.map { it }
|
||||
val relays = innerRelays.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
|
||||
val authorChanges = innerMetadata.map {
|
||||
it.note.author
|
||||
}
|
||||
|
||||
val hasEvent = innerMetadata.map {
|
||||
it.note.event != null
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
|
||||
zapState?.note?.zaps?.isNotEmpty() ?: false ||
|
||||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
|
||||
reactionState?.note?.reactions?.isNotEmpty() ?: false
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val replyCount = innerReplies.map {
|
||||
it.note.replies.size
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val reactionCount = innerReactions.map {
|
||||
it.note.reactions.values.sumOf { it.size }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val boostCount = innerBoosts.map {
|
||||
it.note.boosts.size
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val boostList = innerBoosts.map {
|
||||
it.note.boosts.toImmutableList()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val relayInfo = innerRelays.map {
|
||||
it.note.relays.map {
|
||||
RelayBriefInfo(it)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers() ||
|
||||
@@ -652,28 +747,56 @@ class NoteLiveSet(u: Note) {
|
||||
replies.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
zaps.hasObservers()
|
||||
zaps.hasObservers() ||
|
||||
authorChanges.hasObservers() ||
|
||||
hasEvent.hasObservers() ||
|
||||
hasReactions.hasObservers() ||
|
||||
replyCount.hasObservers() ||
|
||||
reactionCount.hasObservers() ||
|
||||
boostCount.hasObservers() ||
|
||||
boostList.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
innerMetadata.destroy()
|
||||
innerReactions.destroy()
|
||||
innerBoosts.destroy()
|
||||
innerReplies.destroy()
|
||||
innerReports.destroy()
|
||||
innerRelays.destroy()
|
||||
innerZaps.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasObservers()) return
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
}
|
||||
|
||||
fun <Y> map(
|
||||
transform: (NoteState) -> Y
|
||||
): NoteLoadingLiveData<Y> {
|
||||
val initialValue = this.value?.let { transform(it) }
|
||||
val result = NoteLoadingLiveData(note, initialValue)
|
||||
result.addSource(this) { x -> result.value = transform(x) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
if (note is AddressableNote) {
|
||||
@@ -695,3 +818,10 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
|
||||
@Immutable
|
||||
class NoteState(val note: Note)
|
||||
|
||||
@Immutable
|
||||
data class RelayBriefInfo(
|
||||
val url: String,
|
||||
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
|
||||
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
|
||||
)
|
||||
|
||||
@@ -14,4 +14,6 @@ data class RelaySetupInfo(
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
)
|
||||
) {
|
||||
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
@@ -15,9 +15,14 @@ class ThreadAssembler {
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
if (markedAsRoot != null) {
|
||||
// Check to ssee if there is an error in the tag and the root has replies
|
||||
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
|
||||
return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
}
|
||||
}
|
||||
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
// recursive
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
@@ -21,7 +24,6 @@ import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.math.BigDecimal
|
||||
@@ -99,7 +101,11 @@ class User(val pubkeyHex: String) {
|
||||
if (event.id == latestBookmarkList?.id) return
|
||||
|
||||
latestBookmarkList = event
|
||||
liveSet?.bookmarks?.invalidateData()
|
||||
liveSet?.innerBookmarks?.invalidateData()
|
||||
}
|
||||
|
||||
fun clearEOSE() {
|
||||
latestEOSEs = emptyMap()
|
||||
}
|
||||
|
||||
fun updateContactList(event: ContactListEvent) {
|
||||
@@ -109,18 +115,18 @@ class User(val pubkeyHex: String) {
|
||||
latestContactList = event
|
||||
|
||||
// Update following of the current user
|
||||
liveSet?.follows?.invalidateData()
|
||||
liveSet?.innerFollows?.invalidateData()
|
||||
|
||||
// Update Followers of the past user list
|
||||
// Update Followers of the new contact list
|
||||
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
|
||||
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
|
||||
}
|
||||
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
|
||||
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
|
||||
}
|
||||
|
||||
liveSet?.relays?.invalidateData()
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
@@ -128,10 +134,10 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
if (author !in reports.keys) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +147,7 @@ class User(val pubkeyHex: String) {
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,20 +155,20 @@ class User(val pubkeyHex: String) {
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZap(zapRequestOrZapEvent: Note) {
|
||||
if (zapRequestOrZapEvent in zaps.keys) {
|
||||
zaps = zaps.minus(zapRequestOrZapEvent)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapRequestOrZapEvent in zaps.values) {
|
||||
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +222,7 @@ class User(val pubkeyHex: String) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(room)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +230,7 @@ class User(val pubkeyHex: String) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +244,7 @@ class User(val pubkeyHex: String) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +259,7 @@ class User(val pubkeyHex: String) {
|
||||
here.counter++
|
||||
}
|
||||
|
||||
liveSet?.relayInfo?.invalidateData()
|
||||
liveSet?.innerRelayInfo?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
|
||||
@@ -278,7 +284,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
liveSet?.metadata?.invalidateData()
|
||||
liveSet?.innerMetadata?.invalidateData()
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
@@ -372,33 +378,78 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserLiveSet(u: User) {
|
||||
val innerMetadata = UserBundledRefresherLiveData(u)
|
||||
|
||||
// UI Observers line up here.
|
||||
val follows: UserLiveData = UserLiveData(u)
|
||||
val followers: UserLiveData = UserLiveData(u)
|
||||
val reports: UserLiveData = UserLiveData(u)
|
||||
val messages: UserLiveData = UserLiveData(u)
|
||||
val relays: UserLiveData = UserLiveData(u)
|
||||
val relayInfo: UserLiveData = UserLiveData(u)
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val bookmarks: UserLiveData = UserLiveData(u)
|
||||
val innerFollows = UserBundledRefresherLiveData(u)
|
||||
val innerFollowers = UserBundledRefresherLiveData(u)
|
||||
val innerReports = UserBundledRefresherLiveData(u)
|
||||
val innerMessages = UserBundledRefresherLiveData(u)
|
||||
val innerRelays = UserBundledRefresherLiveData(u)
|
||||
val innerRelayInfo = UserBundledRefresherLiveData(u)
|
||||
val innerZaps = UserBundledRefresherLiveData(u)
|
||||
val innerBookmarks = UserBundledRefresherLiveData(u)
|
||||
val innerStatuses = UserBundledRefresherLiveData(u)
|
||||
|
||||
// UI Observers line up here.
|
||||
val metadata = innerMetadata.map { it }
|
||||
val follows = innerFollows.map { it }
|
||||
val followers = innerFollowers.map { it }
|
||||
val reports = innerReports.map { it }
|
||||
val messages = innerMessages.map { it }
|
||||
val relays = innerRelays.map { it }
|
||||
val relayInfo = innerRelayInfo.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
val bookmarks = innerBookmarks.map { it }
|
||||
val statuses = innerStatuses.map { it }
|
||||
|
||||
val profilePictureChanges = innerMetadata.map {
|
||||
it.user.profilePicture()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val nip05Changes = innerMetadata.map {
|
||||
it.user.nip05()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val userMetadataInfo = innerMetadata.map {
|
||||
it.user.info
|
||||
}.distinctUntilChanged()
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers() ||
|
||||
return metadata.hasObservers() ||
|
||||
follows.hasObservers() ||
|
||||
followers.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
messages.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
bookmarks.hasObservers()
|
||||
bookmarks.hasObservers() ||
|
||||
statuses.hasObservers() ||
|
||||
profilePictureChanges.hasObservers() ||
|
||||
nip05Changes.hasObservers() ||
|
||||
userMetadataInfo.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
innerMetadata.destroy()
|
||||
innerFollows.destroy()
|
||||
innerFollowers.destroy()
|
||||
innerReports.destroy()
|
||||
innerMessages.destroy()
|
||||
innerRelays.destroy()
|
||||
innerRelayInfo.destroy()
|
||||
innerZaps.destroy()
|
||||
innerBookmarks.destroy()
|
||||
innerStatuses.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,80 +460,35 @@ data class RelayInfo(
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Chatroom() {
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
var subject: String? = null
|
||||
var subjectCreatedAt: Long? = null
|
||||
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
val newSubject = msg.event?.subject()
|
||||
|
||||
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
|
||||
subject = newSubject
|
||||
subjectCreatedAt = msg.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
|
||||
subject = it.event?.subject()
|
||||
subjectCreatedAt = it.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean {
|
||||
return roomMessages.any { it.author?.pubkeyHex in keySet }
|
||||
}
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
|
||||
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
|
||||
// Recent messages, keep last 100
|
||||
sorted.take(100).toSet()
|
||||
} else {
|
||||
// Old messages, keep the last one.
|
||||
sorted.take(1).toSet()
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false }
|
||||
|
||||
val toRemove = roomMessages.minus(toKeep)
|
||||
roomMessages = toKeep
|
||||
return toRemove
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasObservers()) return
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
|
||||
fun <Y> map(
|
||||
transform: (UserState) -> Y
|
||||
): UserLoadingLiveData<Y> {
|
||||
val initialValue = this.value?.let { transform(it) }
|
||||
val result = UserLoadingLiveData(user, initialValue)
|
||||
result.addSource(this) { x -> result.value = transform(x) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleUserDataSource.add(user)
|
||||
|
||||
@@ -45,7 +45,7 @@ class CashuProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
runCatching {
|
||||
|
||||
@@ -2,10 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
@@ -26,55 +23,46 @@ class Nip05Verifier() {
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
|
||||
}
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server is up and if the address $nip05 is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// check fails on tests
|
||||
checkNotInMainThread()
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
object Nip11CachedRetriever {
|
||||
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
|
||||
val retriever = Nip11Retriever()
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
|
||||
) {
|
||||
val url = retriever.cleanUrl(dirtyUrl)
|
||||
val doc = relayInformationDocumentCache.get(url)
|
||||
|
||||
if (doc != null) {
|
||||
onInfo(doc)
|
||||
} else {
|
||||
Nip11Retriever().loadRelayInfo(
|
||||
url,
|
||||
dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, it)
|
||||
onInfo(it)
|
||||
},
|
||||
onError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Nip11Retriever {
|
||||
enum class ErrorCode {
|
||||
FAIL_TO_ASSEMBLE_URL,
|
||||
FAIL_TO_REACH_SERVER,
|
||||
FAIL_TO_PARSE_RESULT,
|
||||
FAIL_WITH_HTTP_STATUS
|
||||
}
|
||||
|
||||
fun cleanUrl(dirtyUrl: String): String {
|
||||
return if (dirtyUrl.contains("://")) {
|
||||
dirtyUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
} else {
|
||||
"https://$dirtyUrl"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onError: (String, ErrorCode, String?) -> Unit
|
||||
) {
|
||||
try {
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(body))
|
||||
} else {
|
||||
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,9 +52,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.kind),
|
||||
kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
limit = 5
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,16 +10,21 @@ import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.Error
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
data class Counter(var counter: Int)
|
||||
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
var changingFilters = AtomicBoolean()
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
@@ -46,7 +51,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, subscriptionId: String?) {
|
||||
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
|
||||
// Relay.Type.CONNECT -> "connected."
|
||||
// Relay.Type.DISCONNECT -> "disconnected."
|
||||
@@ -54,13 +59,19 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||
// }}")
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
if (type == Relay.Type.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
|
||||
subscriptions[subscriptionId]?.updateEOSE(
|
||||
TimeUtils.fiveMinutesAgo(), // in case people's clock is slighly off.
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
if (success) {
|
||||
markAsSeenOnRelay(eventId, relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
@@ -76,6 +87,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
fun destroy() {
|
||||
stop()
|
||||
Client.unsubscribe(clientListener)
|
||||
scope.cancel()
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
@@ -106,18 +119,19 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateFilters() {
|
||||
bundler.invalidate() {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
scope.launch(Dispatchers.IO) {
|
||||
bundler.invalidate() {
|
||||
// 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.
|
||||
resetFiltersSuspend()
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
@@ -130,6 +144,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
|
||||
|
||||
changingFilters.getAndSet(true)
|
||||
|
||||
updateChannelFilters()
|
||||
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
@@ -161,12 +177,18 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changingFilters.getAndSet(false)
|
||||
}
|
||||
|
||||
open fun consume(event: Event, relay: Relay) {
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
|
||||
open fun markAsSeenOnRelay(eventId: String, relay: Relay) {
|
||||
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
@@ -18,53 +18,66 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createLiveStreamFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
fun createLiveStreamFilter(): List<TypedFilter> {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
),
|
||||
follows?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
tags = mapOf("p" to it),
|
||||
kinds = listOf(LiveActivitiesEvent.kind),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
fun createPublicChatFilter(): List<TypedFilter> {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
val followChats = account.selectedChatsFollowList().toList()
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
ids = followChats,
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
authors = follows,
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
@@ -197,16 +210,16 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = listOfNotNull(
|
||||
createLiveStreamFilter(),
|
||||
createPublicChatFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createLiveStreamTagsFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsGeohashesFilter(),
|
||||
createLiveStreamGeohashesFilter()
|
||||
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
|
||||
listOfNotNull(
|
||||
createLiveStreamTagsFilter(),
|
||||
createLiveStreamGeohashesFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsGeohashesFilter()
|
||||
)
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,12 +55,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8))
|
||||
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -11,10 +11,10 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
|
||||
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
private var channelsToWatch = setOf<Channel>()
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.map { it }
|
||||
private fun createMetadataChangeFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
@@ -32,7 +32,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() && it is PublicChatChannel }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
@@ -53,7 +52,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filterIsInstance<LiveActivitiesChannel>()
|
||||
.filter { it.info == null }
|
||||
|
||||
@@ -81,7 +79,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val reactions = createMetadataChangeFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
|
||||
|
||||
@@ -90,13 +88,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
fun add(eventId: Channel) {
|
||||
if (eventId !in channelsToWatch) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
fun remove(eventId: Channel) {
|
||||
if (eventId in channelsToWatch) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
// Ignores EOSE if it is in the middle of a filter change.
|
||||
if (changingFilters.get()) return@requestNewChannel
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
eventsToWatch.forEach {
|
||||
|
||||
@@ -7,38 +7,65 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
|
||||
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
var usersToWatch = setOf<User>()
|
||||
|
||||
fun createUserFilter(): List<TypedFilter>? {
|
||||
fun createUserMetadataFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
|
||||
authors = firstTimers,
|
||||
limit = 10 * firstTimers.size
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReportFilter(): List<TypedFilter>? {
|
||||
fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestEOSEs
|
||||
)
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(StatusEvent.kind),
|
||||
authors = usersToWatch.map { it.pubkeyHex },
|
||||
since = minLatestEOSEs
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(StatusEvent.kind),
|
||||
authors = usersToWatch.map { it.pubkeyHex },
|
||||
since = minLatestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
|
||||
since = minLatestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel() { time, relayUrl ->
|
||||
@@ -50,17 +77,40 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
val userChannelOnce = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
|
||||
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
|
||||
val neverGottenAnEOSE = mutableSetOf<String>()
|
||||
usersToWatch.forEach {
|
||||
if (it.latestEOSEs.isEmpty()) { // first time
|
||||
neverGottenAnEOSE.add(it.pubkeyHex)
|
||||
} else {
|
||||
it.latestEOSEs.forEach {
|
||||
val minEose = minLatestEOSEs[it.key]
|
||||
if (minEose == null) {
|
||||
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
|
||||
} else if (it.value.time < minEose.time) {
|
||||
minEose.time = it.value.time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userChannel.typedFilters = listOfNotNull(
|
||||
createUserMetadataFilter(minLatestEOSEs),
|
||||
createUserStatusFilter(minLatestEOSEs),
|
||||
createUserReportFilter(minLatestEOSEs)
|
||||
).ifEmpty { null }
|
||||
userChannelFirstTimers.typedFilters = listOfNotNull(
|
||||
createUserMetadataFilter()
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(user: User) {
|
||||
|
||||
@@ -36,8 +36,10 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String?) {
|
||||
eventToWatch = noteId
|
||||
if (eventToWatch != noteId) {
|
||||
eventToWatch = noteId
|
||||
|
||||
invalidateFilters()
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,12 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
authors = follows,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
|
||||
@@ -7,10 +7,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.Bech32
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.toLnUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
@@ -40,96 +37,70 @@ class LightningAddressResolver() {
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
private suspend fun fetchLightningAddressJson(lnaddress: String, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(response.body.string())
|
||||
} else {
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("The receiver's lightning service at $url is not available. It was calculated from the lightning address \"${lnaddress}\". Error: ${it.code}. Check if the server is up and if the lightning address is correct")
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError("Could not resolve $url. Check if the server is up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(response.body.string())
|
||||
} else {
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = {
|
||||
@@ -139,7 +110,15 @@ class LightningAddressResolver() {
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
|
||||
suspend fun lnAddressInvoice(
|
||||
lnaddress: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit
|
||||
) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(
|
||||
@@ -185,9 +164,16 @@ class LightningAddressResolver() {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
onProgress(0.0f)
|
||||
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
|
||||
}
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
} ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
|
||||
onProgress(0.0f)
|
||||
onError("Unable to create a lightning invoice before sending the zap. The receiver's lightning wallet sent the following error: $reason")
|
||||
} ?: run {
|
||||
onProgress(0.0f)
|
||||
onError("nable to create a lightning invoice before sending the zap. Element pr not found in the resulting JSON.")
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
|
||||
@@ -19,34 +19,46 @@ import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
fun consume(event: Event) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
if (LocalCache.notes[event.id] == null) {
|
||||
if (LocalCache.justVerify(event)) {
|
||||
LocalCache.justConsume(event, null)
|
||||
suspend fun consume(event: GiftWrapEvent) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (!notificationManager().areNotificationsEnabled()) return
|
||||
|
||||
val manager = notificationManager()
|
||||
if (manager.areNotificationsEnabled()) {
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
is GiftWrapEvent -> unwrapAndNotify(event)
|
||||
}
|
||||
}
|
||||
// PushNotification Wraps don't include a receiver.
|
||||
// Test with all logged in accounts
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null && acc.keyPair.privKey != null) {
|
||||
consumeIfMatchesAccount(event, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
|
||||
val key = account.keyPair.privKey ?: return
|
||||
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
|
||||
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
|
||||
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
|
||||
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unwrapAndConsume(event: Event, account: Account): Event? {
|
||||
private fun unwrapAndConsume(event: Event, account: Account): Event? {
|
||||
if (!LocalCache.justVerify(event)) return null
|
||||
|
||||
return when (event) {
|
||||
@@ -71,106 +83,106 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
|
||||
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
|
||||
private fun notify(event: ChatMessageEvent, acc: Account) {
|
||||
if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
|
||||
event.pubKey != acc.userProfile().pubkeyHex
|
||||
) { // from the user
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
val chatNote = LocalCache.notes[event.id] ?: return
|
||||
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) {
|
||||
val chatEvent = unwrapAndConsume(giftWrap, account = acc)
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
if (chatEvent is ChatMessageEvent && acc.keyPair.privKey != null) {
|
||||
val chatNote = LocalCache.notes[chatEvent.id] ?: return
|
||||
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
val isKnownRoom = (
|
||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||
) && !acc.isAllHidden(chatRoom.users)
|
||||
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val isKnownRoom = (
|
||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||
) && !acc.isAllHidden(chatRoom.users)
|
||||
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent()
|
||||
notificationManager().sendDMNotification(chatEvent.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent()
|
||||
notificationManager().sendDMNotification(
|
||||
event.id,
|
||||
content,
|
||||
user,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: PrivateDmEvent) {
|
||||
private fun notify(event: PrivateDmEvent, acc: Account) {
|
||||
val note = LocalCache.notes[event.id] ?: return
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(it)
|
||||
) && !acc.isAllHidden(it.users)
|
||||
}.toSet()
|
||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(it)
|
||||
) && !acc.isAllHidden(it.users)
|
||||
}.toSet()
|
||||
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: LnZapEvent) {
|
||||
private fun notify(event: LnZapEvent, acc: Account) {
|
||||
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
}
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
}
|
||||
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,8 @@ import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -64,12 +62,9 @@ class RegisterAccounts(
|
||||
}
|
||||
}
|
||||
|
||||
fun go(notificationToken: String) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
}
|
||||
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,7 @@ object Constants {
|
||||
}
|
||||
|
||||
val defaultRelays = arrayOf(
|
||||
// Free relays for DMs and Follows
|
||||
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
|
||||
// Free relays for only DMs and Follows due to the amount of spam
|
||||
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
// Chats
|
||||
@@ -40,6 +38,7 @@ object Constants {
|
||||
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
// Paid relays
|
||||
RelaySetupInfo("wss://relay.snort.social", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
@@ -51,8 +50,15 @@ object Constants {
|
||||
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
)
|
||||
|
||||
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
val forcedRelayForSearch = arrayOf(
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
)
|
||||
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ class JsonFilter(
|
||||
} else {
|
||||
val jsonObjectSince = factory.objectNode()
|
||||
entries.forEach { sincePairs ->
|
||||
put(sincePairs.key, "${sincePairs.value}")
|
||||
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
|
||||
}
|
||||
put("since", jsonObjectSince)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class Relay(
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
private var isReady: Boolean = false
|
||||
private var usingCompression: Boolean = false
|
||||
|
||||
var eventDownloadCounterInBytes = 0
|
||||
var eventUploadCounterInBytes = 0
|
||||
@@ -85,6 +86,9 @@ class Relay(
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
// BRB is crashing OkHttp Deflater object :(
|
||||
if (url.contains("brb.io")) return
|
||||
|
||||
// If there is a connection, don't wait.
|
||||
if (connectingBlock.getAndSet(true)) {
|
||||
return
|
||||
@@ -115,7 +119,10 @@ class Relay(
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
checkNotInMainThread()
|
||||
|
||||
markConnectionAsReady(response.receivedResponseAtMillis - response.sentRequestAtMillis)
|
||||
markConnectionAsReady(
|
||||
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
|
||||
usingCompression = response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false
|
||||
)
|
||||
|
||||
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
|
||||
onConnected(this@Relay)
|
||||
@@ -175,15 +182,17 @@ class Relay(
|
||||
}
|
||||
}
|
||||
|
||||
fun markConnectionAsReady(pingInMs: Long) {
|
||||
fun markConnectionAsReady(pingInMs: Long, usingCompression: Boolean) {
|
||||
this.afterEOSE = false
|
||||
this.isReady = true
|
||||
this.pingInMs = pingInMs
|
||||
this.usingCompression = usingCompression
|
||||
}
|
||||
|
||||
fun markConnectionAsClosed() {
|
||||
this.socket = null
|
||||
this.isReady = false
|
||||
this.usingCompression = false
|
||||
this.afterEOSE = false
|
||||
this.closingTimeInSeconds = TimeUtils.now()
|
||||
}
|
||||
@@ -239,6 +248,7 @@ class Relay(
|
||||
socket?.close(1000, "Normal close")
|
||||
socket = null
|
||||
isReady = false
|
||||
usingCompression = false
|
||||
afterEOSE = false
|
||||
}
|
||||
|
||||
@@ -284,16 +294,36 @@ class Relay(
|
||||
checkNotInMainThread()
|
||||
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
// specific protocol for this event.
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
} else {
|
||||
if (write) {
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
} else {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
|
||||
// sends all filters after connection is successful.
|
||||
connectAndRun {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (write) {
|
||||
if (signedEvent !is RelayAuthEvent) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||
*/
|
||||
object RelayPool : Relay.Listener {
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
|
||||
private var relays = listOf<Relay>()
|
||||
private var listeners = setOf<Listener>()
|
||||
|
||||
// Backing property to avoid flow emissions from other classes
|
||||
private var _lastStatus = RelayPoolStatus(0, 0)
|
||||
private val _statusFlow = MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val statusFlow: SharedFlow<RelayPoolStatus> = _statusFlow.asSharedFlow()
|
||||
|
||||
fun availableRelays(): Int {
|
||||
return relays.size
|
||||
}
|
||||
@@ -83,11 +85,13 @@ object RelayPool : Relay.Listener {
|
||||
fun addRelay(relay: Relay) {
|
||||
relay.register(this)
|
||||
relays += relay
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
fun removeRelay(relay: Relay) {
|
||||
relay.unregister(this)
|
||||
relays = relays.minus(relay)
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
fun register(listener: Listener) {
|
||||
@@ -116,12 +120,14 @@ object RelayPool : Relay.Listener {
|
||||
|
||||
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
|
||||
listeners.forEach { it.onError(error, subscriptionId, relay) }
|
||||
refreshObservers()
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
|
||||
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
|
||||
refreshObservers()
|
||||
if (type != Relay.Type.EOSE) {
|
||||
updateStatus()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) {
|
||||
@@ -132,20 +138,15 @@ object RelayPool : Relay.Listener {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
scope.launch {
|
||||
live.refresh()
|
||||
private fun updateStatus() {
|
||||
val connected = connectedRelays()
|
||||
val available = availableRelays()
|
||||
if (_lastStatus.connected != connected || _lastStatus.available != available) {
|
||||
_lastStatus = RelayPoolStatus(connected, available)
|
||||
_statusFlow.tryEmit(_lastStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RelayPoolLiveData(val relays: RelayPool) : LiveData<RelayPoolState>(RelayPoolState(relays)) {
|
||||
fun refresh() {
|
||||
postValue(RelayPoolState(relays))
|
||||
}
|
||||
}
|
||||
|
||||
class RelayPoolState(val relays: RelayPool)
|
||||
@Immutable
|
||||
data class RelayPoolStatus(val connected: Int, val available: Int, val isConnected: Boolean = connected > 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
@@ -65,7 +66,10 @@ class MainActivity : AppCompatActivity() {
|
||||
themeViewModel.onChange(LocalPreferences.getTheme())
|
||||
AmethystTheme(themeViewModel) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background
|
||||
) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(this@MainActivity)
|
||||
}
|
||||
@@ -81,7 +85,8 @@ class MainActivity : AppCompatActivity() {
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.build()
|
||||
|
||||
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
val connectivityManager =
|
||||
getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
connectivityManager.requestNetwork(networkRequest, networkCallback)
|
||||
}
|
||||
|
||||
@@ -97,10 +102,13 @@ class MainActivity : AppCompatActivity() {
|
||||
ServiceManager.start(this@MainActivity)
|
||||
}
|
||||
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
ServiceManager.cleanObservers()
|
||||
// if (BuildConfig.DEBUG) {
|
||||
debugState(this)
|
||||
// }
|
||||
@@ -125,7 +133,7 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
ServiceManager.cleanUp()
|
||||
ServiceManager.trimMemory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +158,8 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasMobileData =
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
|
||||
@@ -175,9 +184,15 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
return super.createIntent(context, input).apply {
|
||||
// Force only images and videos to be selectable
|
||||
// Force OPEN Document because of the resulting URI must be passed to the
|
||||
// Playback service and the picker's permissions only allow the activity to read the URI
|
||||
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
// Force only images and videos to be selectable
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () ->
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
searchBarViewModel.clear()
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
onClose()
|
||||
|
||||
@@ -51,7 +51,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -79,13 +80,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
var showRelaysDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var relayList = account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
var relayList = account.activeWriteRelays()
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
@@ -101,7 +96,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
) {
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
list = relayList,
|
||||
preSelectedList = relayList,
|
||||
onClose = {
|
||||
showRelaysDialog = false
|
||||
},
|
||||
@@ -123,7 +118,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
@@ -221,7 +216,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
postViewModel.imageUploadingError.emit("Unable to load file")
|
||||
postViewModel.imageUploadingError.emit("Unable to load thumbnail, but the video can be uploaded")
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
@@ -11,8 +13,6 @@ 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
|
||||
@@ -21,51 +21,11 @@ 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.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@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
|
||||
@@ -80,10 +40,10 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = textMin,
|
||||
onValueChange = { textMin = it },
|
||||
value = pollViewModel.valueMinimum?.toString() ?: "",
|
||||
onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
@@ -98,11 +58,14 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
OutlinedTextField(
|
||||
value = textMax,
|
||||
onValueChange = { textMax = it },
|
||||
value = pollViewModel.valueMaximum?.toString() ?: "",
|
||||
onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(150.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
|
||||
label = {
|
||||
Text(
|
||||
@@ -118,10 +81,25 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_zap_value_min_max_explainer),
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
modifier = Modifier.padding(vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NewPollVoteValueRangePreview() {
|
||||
NewPollVoteValueRange(NewPostViewModel())
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
NewPollVoteValueRange(NewPostViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
@@ -97,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
@@ -110,7 +112,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(
|
||||
onClose: () -> Unit,
|
||||
@@ -132,13 +133,7 @@ fun NewPostView(
|
||||
var showRelaysDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var relayList = account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
var relayList = account.activeWriteRelays()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account, baseReplyTo, quote)
|
||||
@@ -166,314 +161,340 @@ fun NewPostView(
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
list = relayList,
|
||||
onClose = {
|
||||
showRelaysDialog = false
|
||||
},
|
||||
onPost = {
|
||||
relayList = it
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
preSelectedList = relayList,
|
||||
onClose = {
|
||||
showRelaysDialog = false
|
||||
},
|
||||
onPost = {
|
||||
relayList = it
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
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 = {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = {
|
||||
showRelaysDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
PostButton(
|
||||
onPost = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPost(relayList = relayList)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = {
|
||||
showRelaysDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
PostButton(
|
||||
onPost = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPost(relayList = relayList)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
},
|
||||
backgroundColor = MaterialTheme.colors.surface,
|
||||
elevation = 0.dp
|
||||
)
|
||||
}
|
||||
) { pad ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = Size10dp,
|
||||
top = pad.calculateTopPadding(),
|
||||
end = Size10dp,
|
||||
bottom = pad.calculateBottomPadding()
|
||||
)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp)
|
||||
.imePadding()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
.weight(1f)
|
||||
) {
|
||||
postViewModel.originalNote?.let {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
modifier = MaterialTheme.colors.replyModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
Notifying(postViewModel.mentions?.toImmutableList()) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
|
||||
if (enableMessageInterface) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
SendDirectMessageTo(postViewModel = postViewModel)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
postViewModel.originalNote?.let {
|
||||
Row(Modifier.heightIn(max = 200.dp)) {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
modifier = MaterialTheme.colors.replyModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageField(postViewModel)
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
PollField(postViewModel)
|
||||
Row() {
|
||||
Notifying(postViewModel.mentions?.toImmutableList()) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsToMarkAsSensitive) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
ContentSensitivityExplainer(postViewModel)
|
||||
if (enableMessageInterface) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
SendDirectMessageTo(postViewModel = postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsToAddGeoHash) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
LocationAsHash(postViewModel)
|
||||
MessageField(postViewModel)
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
PollField(postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsForwardZapTo) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
FowardZapTo(postViewModel)
|
||||
if (postViewModel.wantsToMarkAsSensitive) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
ContentSensitivityExplainer(postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val url = postViewModel.contentToAddUrl
|
||||
if (url != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
account.defaultFileServer,
|
||||
onAdd = { description, server, sensitiveContent ->
|
||||
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
|
||||
account.changeDefaultFileServer(server)
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
postViewModel.imageUploadingError.emit(it)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
if (postViewModel.wantsToAddGeoHash) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
LocationAsHash(postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val user = postViewModel.account?.userProfile()
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
if (postViewModel.wantsForwardZapTo) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
FowardZapTo(postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (lud16 != null && postViewModel.wantsInvoice) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
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
|
||||
val url = postViewModel.contentToAddUrl
|
||||
if (url != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
account.defaultFileServer,
|
||||
onAdd = { description, server, sensitiveContent ->
|
||||
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
|
||||
account.changeDefaultFileServer(server)
|
||||
},
|
||||
onClose = {
|
||||
postViewModel.wantsInvoice = false
|
||||
}
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
postViewModel.imageUploadingError.emit(it)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lud16 != null && postViewModel.wantsZapraiser) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
ZapRaiserRequest(
|
||||
stringResource(id = R.string.zapraiser),
|
||||
postViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
val user = postViewModel.account?.userProfile()
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl =
|
||||
myUrlPreview.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.subtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
if (lud16 != null && postViewModel.wantsInvoice) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
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
|
||||
}
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
}
|
||||
} else if (startsWithNIP19Scheme(myUrlPreview)) {
|
||||
val bgColor = MaterialTheme.colors.background
|
||||
val backgroundColor = remember {
|
||||
mutableStateOf(bgColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
if (lud16 != null && postViewModel.wantsZapraiser) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
ZapRaiserRequest(
|
||||
stringResource(id = R.string.zapraiser),
|
||||
postViewModel
|
||||
)
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl =
|
||||
myUrlPreview.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.subtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
}
|
||||
} else if (startsWithNIP19Scheme(myUrlPreview)) {
|
||||
val bgColor = MaterialTheme.colors.background
|
||||
val backgroundColor = remember {
|
||||
mutableStateOf(bgColor)
|
||||
}
|
||||
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val userSuggestions = postViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserLine(item, accountViewModel) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
val userSuggestions = postViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserLine(item, accountViewModel) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
// These should be hashtag recommendations the user selects in the future.
|
||||
// val hashtag = stringResource(R.string.poll_hashtag)
|
||||
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier
|
||||
) {
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddInvoice) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
if (postViewModel.canUsePoll) {
|
||||
// These should be hashtag recommendations the user selects in the future.
|
||||
// val hashtag = stringResource(R.string.poll_hashtag)
|
||||
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddZapRaiser) {
|
||||
AddZapraiserButton(postViewModel.wantsZapraiser) {
|
||||
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
|
||||
if (postViewModel.canAddInvoice) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MarkAsSensitive(postViewModel) {
|
||||
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
|
||||
}
|
||||
if (postViewModel.canAddZapRaiser) {
|
||||
AddZapraiserButton(postViewModel.wantsZapraiser) {
|
||||
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
|
||||
}
|
||||
}
|
||||
|
||||
AddGeoHash(postViewModel) {
|
||||
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
||||
}
|
||||
MarkAsSensitive(postViewModel) {
|
||||
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
|
||||
}
|
||||
|
||||
ForwardZapTo(postViewModel) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
AddGeoHash(postViewModel) {
|
||||
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
|
||||
}
|
||||
|
||||
ForwardZapTo(postViewModel) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,6 +512,8 @@ private fun PollField(postViewModel: NewPostViewModel) {
|
||||
NewPollOption(postViewModel, index)
|
||||
}
|
||||
|
||||
NewPollVoteValueRange(postViewModel)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
postViewModel.pollOptions[postViewModel.pollOptions.size] =
|
||||
@@ -887,19 +910,18 @@ fun DisplayLocationInTitle(geohash: String) {
|
||||
fun Notifying(baseMentions: ImmutableList<User>?, onClick: (User) -> Unit) {
|
||||
val mentions = baseMentions?.toSet()
|
||||
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
if (!mentions.isNullOrEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.reply_notify),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
modifier = Modifier.align(CenterVertically)
|
||||
)
|
||||
|
||||
mentions.forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
innerUserState?.user?.let { myUser ->
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
|
||||
val tags = remember(innerUserState) {
|
||||
myUser.info?.latestMetadata?.tags?.toImmutableListOfLists()
|
||||
}
|
||||
@@ -1160,10 +1182,10 @@ private fun MarkAsSensitive(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CloseButton(onCancel: () -> Unit) {
|
||||
fun CloseButton(onPress: () -> Unit) {
|
||||
Button(
|
||||
onClick = {
|
||||
onCancel()
|
||||
onPress()
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
@@ -1348,8 +1370,8 @@ fun ImageVideoDescription(
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
onError("Unable to load file")
|
||||
Log.e("NewPostView", "Couldn't create thumbnail for $uri")
|
||||
onError("Unable to load thumbnail")
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var wantsPoll by mutableStateOf(false)
|
||||
var zapRecipients = mutableStateListOf<HexKey>()
|
||||
var pollOptions = newStateMapPollOptions()
|
||||
var valueMaximum: Int? = null
|
||||
var valueMinimum: Int? = null
|
||||
var valueMaximum by mutableStateOf<Int?>(null)
|
||||
var valueMinimum by mutableStateOf<Int?>(null)
|
||||
var consensusThreshold: Int? = null
|
||||
var closedAt: Int? = null
|
||||
|
||||
@@ -494,7 +494,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
|
||||
(!wantsZapraiser || zapRaiserAmount != null) &&
|
||||
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
|
||||
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) &&
|
||||
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
|
||||
contentToAddUrl == null
|
||||
}
|
||||
|
||||
@@ -612,6 +612,50 @@ open class NewPostViewModel() : ViewModel() {
|
||||
nip24 = !nip24
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMinZapAmountForPoll(textMin: String) {
|
||||
if (textMin.isNotEmpty()) {
|
||||
try {
|
||||
val int = textMin.toInt()
|
||||
if (int < 1) {
|
||||
valueMinimum = null
|
||||
} else {
|
||||
valueMinimum = int
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
} else {
|
||||
valueMinimum = null
|
||||
}
|
||||
|
||||
checkMinMax()
|
||||
}
|
||||
|
||||
fun updateMaxZapAmountForPoll(textMax: String) {
|
||||
if (textMax.isNotEmpty()) {
|
||||
try {
|
||||
val int = textMax.toInt()
|
||||
if (int < 1) {
|
||||
valueMaximum = null
|
||||
} else {
|
||||
valueMaximum = int
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
} else {
|
||||
valueMaximum = null
|
||||
}
|
||||
|
||||
checkMinMax()
|
||||
}
|
||||
|
||||
fun checkMinMax() {
|
||||
if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) {
|
||||
isValidvalueMinimum.value = false
|
||||
isValidvalueMaximum.value = false
|
||||
} else {
|
||||
isValidvalueMinimum.value = true
|
||||
isValidvalueMaximum.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class GeohashPrecision(val digits: Int) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
@@ -24,6 +25,7 @@ class NewRelayListViewModel : ViewModel() {
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
clear()
|
||||
loadRelayDocuments()
|
||||
}
|
||||
|
||||
fun create() {
|
||||
@@ -36,17 +38,34 @@ class NewRelayListViewModel : ViewModel() {
|
||||
clear()
|
||||
}
|
||||
|
||||
fun loadRelayDocuments() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_relays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
onInfo = {
|
||||
togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_relays.update {
|
||||
var relayFile = account.userProfile().latestContactList?.relays()
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) {
|
||||
relayFile = relayFile + Pair(
|
||||
Constants.forcedRelayForSearch.url,
|
||||
ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
|
||||
)
|
||||
if (relayFile?.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } == true) {
|
||||
relayFile = relayFile + Constants.forcedRelayForSearch.map {
|
||||
Pair(
|
||||
it.url,
|
||||
ContactListEvent.ReadWrite(it.read, it.write)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relayFile != null) {
|
||||
|
||||
@@ -67,7 +67,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -28,27 +25,24 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RelayInformationDialog(
|
||||
onClose: () -> Unit,
|
||||
relayBriefInfo: RelayBriefInfo,
|
||||
relayInfo: RelayInformation,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
@@ -74,23 +68,30 @@ fun RelayInformationDialog(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Title(relayInfo.name ?: "")
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = StdPadding.fillMaxWidth()) {
|
||||
Column() {
|
||||
RenderRelayIcon(
|
||||
relayBriefInfo.favIcon,
|
||||
Size55dp
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
SectionContent(relayInfo.description ?: "")
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row() {
|
||||
Title(relayInfo.name?.trim() ?: "")
|
||||
}
|
||||
|
||||
Row() {
|
||||
SubtitleContent(relayInfo.description?.trim() ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(stringResource(R.string.owner))
|
||||
@@ -212,7 +213,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
|
||||
val text = item.toString().padStart(2, '0')
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
ClickableUrl(
|
||||
urlText = "$text",
|
||||
urlText = text,
|
||||
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
|
||||
)
|
||||
}
|
||||
@@ -222,7 +223,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
|
||||
val text = item.padStart(2, '0')
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
ClickableUrl(
|
||||
urlText = "$text",
|
||||
urlText = text,
|
||||
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
|
||||
)
|
||||
}
|
||||
@@ -258,13 +259,18 @@ private fun DisplayOwnerInformation(
|
||||
|
||||
@Composable
|
||||
fun Title(text: String) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(
|
||||
text = text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp
|
||||
)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubtitleContent(text: String) {
|
||||
Text(
|
||||
text = text
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -285,85 +291,3 @@ fun SectionContent(text: String) {
|
||||
text = text
|
||||
)
|
||||
}
|
||||
|
||||
fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
onInfo: (RelayInformation) -> Unit
|
||||
) {
|
||||
try {
|
||||
val url = if (dirtyUrl.contains("://")) {
|
||||
dirtyUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
} else {
|
||||
"https://$dirtyUrl"
|
||||
}
|
||||
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(body))
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +29,27 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class RelayList(
|
||||
val relay: Relay,
|
||||
val relayInfo: RelayBriefInfo,
|
||||
val isSelected: Boolean
|
||||
)
|
||||
|
||||
data class RelayInfoDialog(
|
||||
val relayBriefInfo: RelayBriefInfo,
|
||||
val relayInfo: RelayInformation
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RelaySelectionDialog(
|
||||
list: List<Relay>,
|
||||
preSelectedList: List<Relay>,
|
||||
onClose: () -> Unit,
|
||||
onPost: (list: List<Relay>) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -49,34 +57,29 @@ fun RelaySelectionDialog(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val relayList = accountViewModel.account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: accountViewModel.account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
|
||||
var relays by remember {
|
||||
mutableStateOf(
|
||||
relayList.map {
|
||||
accountViewModel.account.activeWriteRelays().map {
|
||||
RelayList(
|
||||
it,
|
||||
list.any { relay -> it.url == relay.url }
|
||||
relay = it,
|
||||
relayInfo = RelayBriefInfo(it.url),
|
||||
isSelected = preSelectedList.any { relay -> it.url == relay.url }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
|
||||
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
|
||||
|
||||
if (relayInfo != null) {
|
||||
relayInfo?.let {
|
||||
RelayInformationDialog(
|
||||
onClose = {
|
||||
relayInfo = null
|
||||
},
|
||||
relayInfo = relayInfo!!,
|
||||
accountViewModel,
|
||||
nav
|
||||
relayInfo = it.relayInfo,
|
||||
relayBriefInfo = it.relayBriefInfo,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,19 +113,19 @@ fun RelaySelectionDialog(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(
|
||||
onCancel = {
|
||||
onPress = {
|
||||
onClose()
|
||||
}
|
||||
)
|
||||
|
||||
PostButton(
|
||||
SaveButton(
|
||||
onPost = {
|
||||
val selectedRelays = relays.filter { it.isSelected }
|
||||
if (selectedRelays.isEmpty()) {
|
||||
scope.launch {
|
||||
Toast.makeText(context, context.getString(R.string.select_a_relay_to_continue), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
return@PostButton
|
||||
return@SaveButton
|
||||
}
|
||||
onPost(selectedRelays.map { it.relay })
|
||||
onClose()
|
||||
@@ -153,10 +156,7 @@ fun RelaySelectionDialog(
|
||||
key = { _, item -> item.relay.url }
|
||||
) { index, item ->
|
||||
RelaySwitch(
|
||||
text = item.relay.url
|
||||
.removePrefix("ws://")
|
||||
.removePrefix("wss://")
|
||||
.removeSuffix("/"),
|
||||
text = item.relayInfo.displayUrl,
|
||||
checked = item.isSelected,
|
||||
onClick = {
|
||||
relays = relays.mapIndexed { j, item ->
|
||||
@@ -168,9 +168,30 @@ fun RelaySelectionDialog(
|
||||
}
|
||||
},
|
||||
onLongPress = {
|
||||
loadRelayInfo(item.relay.url, context, scope) {
|
||||
relayInfo = it
|
||||
}
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
item.relay.url,
|
||||
onInfo = {
|
||||
relayInfo = RelayInfoDialog(RelayBriefInfo(item.relay.url), it)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg = when (errorCode) {
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
msg,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,38 +59,38 @@ fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color)
|
||||
)
|
||||
)
|
||||
newWord + restOfWord
|
||||
} else if (Patterns.WEB_URL.matcher(word).matches()) {
|
||||
val startIndex = builderBefore.toString().length
|
||||
val endIndex = startIndex + word.length
|
||||
|
||||
val startNew = builderAfter.toString().length
|
||||
val endNew = startNew + word.length
|
||||
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, endNew)
|
||||
)
|
||||
)
|
||||
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
} else {
|
||||
builderBefore.append(word + " ")
|
||||
builderAfter.append(word + " ")
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// if it can't parse the key, don't try to change.
|
||||
builderBefore.append(word + " ")
|
||||
builderAfter.append(word + " ")
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
)
|
||||
|
||||
val newText = toAnnotatedString()
|
||||
|
||||
newText.split("\\s+".toRegex()).filter { word ->
|
||||
Patterns.WEB_URL.matcher(word).matches()
|
||||
}.forEach {
|
||||
val startIndex = text.indexOf(it)
|
||||
val endIndex = startIndex + it.length
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = color,
|
||||
textDecoration = TextDecoration.None
|
||||
),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
}
|
||||
|
||||
substitutions.forEach {
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
|
||||
@@ -48,7 +48,7 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit, navScrollToTop: (Route, Boolean) -> Unit) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -62,11 +62,9 @@ fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val postViewModel: NewMediaModel = viewModel()
|
||||
postViewModel.onceUploaded {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
// awaits an refresh on the list
|
||||
delay(250)
|
||||
delay(500)
|
||||
withContext(Dispatchers.Main) {
|
||||
val route = Route.Video.route.replace("{scrollToTop}", "true")
|
||||
nav(route)
|
||||
navScrollToTop(Route.Video, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -21,6 +22,8 @@ class BundledUpdate(
|
||||
val delay: Long,
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
val scope = CoroutineScope(dispatcher + SupervisorJob())
|
||||
|
||||
private var onlyOneInBlock = AtomicBoolean()
|
||||
private var invalidatesAgain = false
|
||||
|
||||
@@ -32,8 +35,7 @@ class BundledUpdate(
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + dispatcher)
|
||||
scope.launch {
|
||||
scope.launch(dispatcher) {
|
||||
try {
|
||||
onUpdate()
|
||||
delay(delay)
|
||||
@@ -48,6 +50,10 @@ class BundledUpdate(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +64,8 @@ class BundledInsert<T>(
|
||||
val delay: Long,
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
val scope = CoroutineScope(dispatcher + SupervisorJob())
|
||||
|
||||
private var onlyOneInBlock = AtomicBoolean()
|
||||
private var queue = LinkedBlockingQueue<T>()
|
||||
|
||||
@@ -69,8 +77,7 @@ class BundledInsert<T>(
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + dispatcher)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
scope.launch(dispatcher) {
|
||||
try {
|
||||
val mySet = mutableSetOf<T>()
|
||||
queue.drainTo(mySet)
|
||||
@@ -88,4 +95,8 @@ class BundledInsert<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
@@ -12,6 +13,7 @@ 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.R
|
||||
import com.vitorpamplona.quartz.encoders.LnWithdrawalUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -41,10 +43,7 @@ fun MayBeWithdrawal(lnurlWord: String) {
|
||||
@Composable
|
||||
fun ClickableWithdrawal(withdrawalString: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val uri = remember(withdrawalString) {
|
||||
Uri.parse("lightning:$withdrawalString")
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val withdraw = remember(withdrawalString) {
|
||||
AnnotatedString("$withdrawalString ")
|
||||
@@ -53,9 +52,18 @@ fun ClickableWithdrawal(withdrawalString: String) {
|
||||
ClickableText(
|
||||
text = withdraw,
|
||||
onClick = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.lightning_wallets_not_found),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -66,6 +67,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String, amount: String?) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -118,9 +120,18 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
runCatching {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.lightning_wallets_not_found),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -152,23 +153,25 @@ fun InvoiceRequest(
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
zapRequest?.toJson(),
|
||||
onSuccess = onSuccess,
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
onClose()
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
zapRequest?.toJson(),
|
||||
onSuccess = onSuccess,
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
|
||||
@@ -30,8 +30,6 @@ import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
@@ -421,9 +419,7 @@ private fun ObserveNIP19Event(
|
||||
|
||||
@Composable
|
||||
fun ObserveNote(note: Note, onRefresh: () -> Unit) {
|
||||
val loadedNoteId by note.live().metadata.map {
|
||||
it.note.event?.id()
|
||||
}.distinctUntilChanged().observeAsState(note.event?.id())
|
||||
val loadedNoteId by note.live().metadata.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = loadedNoteId) {
|
||||
if (loadedNoteId != null) {
|
||||
@@ -460,9 +456,7 @@ private fun ObserveNIP19User(
|
||||
|
||||
@Composable
|
||||
private fun ObserveUser(user: User, onRefresh: () -> Unit) {
|
||||
val loadedUserMetaId by user.live().metadata.map {
|
||||
it.user.info?.latestMetadata?.id
|
||||
}.distinctUntilChanged().observeAsState(user.info?.latestMetadata?.id)
|
||||
val loadedUserMetaId by user.live().metadata.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = loadedUserMetaId) {
|
||||
if (loadedUserMetaId != null) {
|
||||
@@ -854,9 +848,7 @@ private fun DisplayUserFromTag(
|
||||
val route = remember { "User/${baseUser.pubkeyHex}" }
|
||||
val hex = remember { baseUser.pubkeyDisplayHex() }
|
||||
|
||||
val meta by baseUser.live().metadata.map {
|
||||
it.user.info
|
||||
}.distinctUntilChanged().observeAsState(baseUser.info)
|
||||
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
|
||||
|
||||
Crossfade(targetState = meta) {
|
||||
Row() {
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -30,9 +29,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SlidingCarousel(
|
||||
pagerState: PagerState,
|
||||
modifier: Modifier = Modifier,
|
||||
pagerState: PagerState = remember { PagerState() },
|
||||
itemsCount: Int,
|
||||
itemContent: @Composable (index: Int) -> Unit
|
||||
) {
|
||||
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
|
||||
@@ -40,7 +38,7 @@ fun SlidingCarousel(
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
|
||||
HorizontalPager(state = pagerState) { page ->
|
||||
itemContent(page)
|
||||
}
|
||||
|
||||
@@ -55,7 +53,7 @@ fun SlidingCarousel(
|
||||
) {
|
||||
DotsIndicator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
totalDots = itemsCount,
|
||||
totalDots = pagerState.pageCount,
|
||||
selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage,
|
||||
dotSize = 8.dp
|
||||
)
|
||||
|
||||
@@ -12,8 +12,6 @@ import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
|
||||
@@ -37,10 +35,8 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel)
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
LaunchedEffect(url) {
|
||||
launch(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(url) {
|
||||
urlPreviewState = it
|
||||
}
|
||||
accountViewModel.urlPreview(url) {
|
||||
urlPreviewState = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,6 +704,7 @@ fun ControlWhenPlayerIsActive(
|
||||
|
||||
controller.addListener(listener)
|
||||
onDispose {
|
||||
view.keepScreenOn = false
|
||||
controller.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,18 @@ import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
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.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
@@ -46,10 +53,12 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
@@ -77,11 +86,15 @@ import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -184,13 +197,20 @@ fun ZoomableContentView(
|
||||
roundedCorner: Boolean,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
// store the dialog open or close state
|
||||
val shareOpen = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (shareOpen.value) {
|
||||
ShareImageAction(shareOpen, content) { shareOpen.value = false }
|
||||
}
|
||||
|
||||
var mainImageModifier = if (roundedCorner) {
|
||||
MaterialTheme.colors.imageModifier
|
||||
} else {
|
||||
@@ -200,12 +220,12 @@ fun ZoomableContentView(
|
||||
if (content is ZoomableUrlContent) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
|
||||
onLongClick = { shareOpen.value = true }
|
||||
)
|
||||
} else if (content is ZoomablePreloadedContent) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
|
||||
onLongClick = { shareOpen.value = true }
|
||||
)
|
||||
} else {
|
||||
mainImageModifier = mainImageModifier.clickable {
|
||||
@@ -617,7 +637,7 @@ fun ZoomableImageDialog(
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
val pagerState: PagerState = rememberPagerState()
|
||||
val pagerState: PagerState = rememberPagerState() { allImages.size }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
@@ -628,12 +648,10 @@ fun ZoomableImageDialog(
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
itemsCount = allImages.size,
|
||||
itemContent = { index ->
|
||||
RenderImageOrVideo(allImages[index], false, accountViewModel)
|
||||
}
|
||||
)
|
||||
pagerState = pagerState
|
||||
) { index ->
|
||||
RenderImageOrVideo(allImages[index], false, accountViewModel)
|
||||
}
|
||||
} else {
|
||||
RenderImageOrVideo(imageUrl, false, accountViewModel)
|
||||
}
|
||||
@@ -645,11 +663,14 @@ fun ZoomableImageDialog(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onDismiss)
|
||||
CloseButton(onPress = onDismiss)
|
||||
|
||||
val myContent = allImages[pagerState.currentPage]
|
||||
if (myContent is ZoomableUrlContent) {
|
||||
SaveToGallery(url = myContent.url)
|
||||
Row() {
|
||||
CopyToClipboard(content = myContent)
|
||||
SaveToGallery(url = myContent.url)
|
||||
}
|
||||
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
|
||||
SaveToGallery(
|
||||
localFile = myContent.localFile,
|
||||
@@ -662,6 +683,64 @@ fun ZoomableImageDialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyToClipboard(
|
||||
content: ZoomableContent
|
||||
) {
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = Size5dp),
|
||||
onClick = { popupExpanded.value = true },
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
tint = Color.White,
|
||||
imageVector = Icons.Default.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringResource(R.string.copy_url_to_clipboard)
|
||||
)
|
||||
|
||||
ShareImageAction(popupExpanded, content) { popupExpanded.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareImageAction(
|
||||
popupExpanded:
|
||||
MutableState<Boolean>,
|
||||
content: ZoomableContent,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded.value,
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
if (content is ZoomableUrlContent) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.url)); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_url_to_clipboard))
|
||||
}
|
||||
if (content.uri != null) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_the_note_id_to_the_clipboard))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content is ZoomablePreloadedContent) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_the_note_id_to_the_clipboard))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(content: ZoomableContent, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
|
||||
val mainModifier = Modifier
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
@@ -50,7 +49,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
.reversed()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
val me = account.userProfile()
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
@@ -38,7 +37,6 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
|
||||
.reversed()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
val me = account.userProfile()
|
||||
|
||||
@@ -11,13 +11,19 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
open class DiscoverLiveFeedFilter(
|
||||
val account: Account
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
|
||||
return account.userProfile().pubkeyHex + "-" + followList()
|
||||
}
|
||||
|
||||
open fun followList(): String {
|
||||
return account.defaultDiscoveryFollowList
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultDiscoveryFollowList == PeopleListEvent.blockList
|
||||
return followList() == PeopleListEvent.blockList
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -39,15 +45,15 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingGeohashSet = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingKeySet = account.selectedUsersFollowList(followList()) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(followList()) ?: emptySet()
|
||||
val followingGeohashSet = account.selectedGeohashesFollowList(followList()) ?: emptySet()
|
||||
|
||||
val activities = collection
|
||||
.asSequence()
|
||||
.filter { it.event is LiveActivitiesEvent }
|
||||
.filter {
|
||||
isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(
|
||||
isGlobal || (it.event as LiveActivitiesEvent).participantsIntersect(followingKeySet) || it.event?.isTaggedHashes(
|
||||
followingTagSet
|
||||
) == true || it.event?.isTaggedGeoHashes(
|
||||
followingGeohashSet
|
||||
@@ -61,7 +67,7 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
val followingKeySet = account.selectedUsersFollowList(followList())
|
||||
|
||||
val counter = ParticipantListBuilder()
|
||||
val participantCounts = collection.associate {
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
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.Note
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
|
||||
class DiscoverLiveNowFeedFilter(account: Account) : DiscoverLiveFeedFilter(account) {
|
||||
class DiscoverLiveNowFeedFilter(
|
||||
account: Account
|
||||
) : DiscoverLiveFeedFilter(account) {
|
||||
override fun followList(): String {
|
||||
// uses follows by default, but other lists if they were selected in the top bar
|
||||
val currentList = super.followList()
|
||||
return if (currentList == GLOBAL_FOLLOWS) {
|
||||
KIND3_FOLLOWS
|
||||
} else {
|
||||
currentList
|
||||
}
|
||||
}
|
||||
|
||||
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val allItems = super.innerApplyFilter(collection)
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
abstract class FeedFilter<T> {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun loadTop(): List<T> {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -33,7 +31,6 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
|
||||
abstract fun applyFilter(collection: Set<T>): Set<T>
|
||||
abstract fun sort(collection: Set<T>): List<T>
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
|
||||
checkNotInMainThread()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
@@ -34,6 +35,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) &&
|
||||
it.event?.isTaggedGeoHash(myTag) == true
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
@@ -34,6 +35,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) &&
|
||||
it.event?.isTaggedHash(myTag) == true
|
||||
|
||||
@@ -38,7 +38,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>, ignoreAddressables: Boolean): Set<Note> {
|
||||
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
|
||||
val gRelays = account.convertGlobalRelays()
|
||||
val gRelays = account.activeGlobalRelays()
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Immutable
|
||||
class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
|
||||
class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return noteId
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()
|
||||
val cachedSignatures: MutableMap<Note, Note.LevelSignature> = mutableMapOf()
|
||||
val followingSet = account.selectedUsersFollowList(KIND3_FOLLOWS) ?: emptySet()
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
|
||||
val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Currently orders by date of each event, descending, at each level of the reply stack
|
||||
val order = compareByDescending<Note> { it.replyLevelSignature(eventsToWatch, cachedSignatures) }
|
||||
val order = compareByDescending<Note> {
|
||||
it.replyLevelSignature(eventsInHex, cachedSignatures, account.userProfile(), followingSet, now).signature
|
||||
}
|
||||
|
||||
return eventsToWatch.sortedWith(order)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -80,7 +83,12 @@ fun AppNavigation(
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController, startDestination = Route.Home.route) {
|
||||
NavHost(
|
||||
navController,
|
||||
startDestination = Route.Home.route,
|
||||
enterTransition = { fadeIn(animationSpec = tween(200)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(200)) }
|
||||
) {
|
||||
Route.Home.let { route ->
|
||||
composable(route.route, route.arguments, content = { it ->
|
||||
val nip47 = it.arguments?.getString("nip47")
|
||||
@@ -215,6 +223,7 @@ fun AppNavigation(
|
||||
composable(route.route, route.arguments, content = {
|
||||
ChatroomScreen(
|
||||
roomId = it.arguments?.getString("id"),
|
||||
draftMessage = it.arguments?.getString("message"),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AppBarDefaults
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.Divider
|
||||
@@ -91,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ShortCommunityHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -107,8 +110,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowVideoStreaming
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
@@ -118,7 +121,6 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -232,7 +234,9 @@ private fun CommunityTopBar(
|
||||
ShortCommunityHeader(baseNote, fontWeight = FontWeight.Medium, accountViewModel, nav)
|
||||
},
|
||||
extendableRow = {
|
||||
LongCommunityHeader(baseNote, accountViewModel, nav)
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
},
|
||||
popBack = navPopBack
|
||||
)
|
||||
@@ -298,6 +302,17 @@ private fun RenderRoomTopBar(
|
||||
}
|
||||
}
|
||||
},
|
||||
extendableRow = {
|
||||
LoadUser(baseUserHex = room.users.first()) {
|
||||
if (it != null) {
|
||||
UserCompose(
|
||||
baseUser = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
popBack = navPopBack
|
||||
)
|
||||
} else {
|
||||
@@ -309,10 +324,17 @@ private fun RenderRoomTopBar(
|
||||
size = Size34dp
|
||||
)
|
||||
|
||||
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), fontWeight = FontWeight.Medium, accountViewModel.userProfile())
|
||||
RoomNameOnlyDisplay(
|
||||
room,
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f),
|
||||
fontWeight = FontWeight.Medium,
|
||||
accountViewModel.userProfile()
|
||||
)
|
||||
},
|
||||
extendableRow = {
|
||||
LongRoomHeader(room, accountViewModel, nav)
|
||||
LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav)
|
||||
},
|
||||
popBack = navPopBack
|
||||
)
|
||||
@@ -343,7 +365,7 @@ private fun ChannelTopBar(
|
||||
)
|
||||
},
|
||||
extendableRow = {
|
||||
LongChannelHeader(baseChannel, accountViewModel, nav)
|
||||
LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav)
|
||||
},
|
||||
popBack = navPopBack
|
||||
)
|
||||
@@ -429,8 +451,6 @@ fun GenericMainTopBar(
|
||||
nav: (String) -> Unit,
|
||||
content: @Composable (AccountViewModel) -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = BottomTopHeight) {
|
||||
MyTopAppBar(
|
||||
elevation = 0.dp,
|
||||
@@ -454,6 +474,7 @@ fun GenericMainTopBar(
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LoggedInUserPictureDrawer(accountViewModel) {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.open()
|
||||
@@ -484,10 +505,9 @@ private fun LoggedInUserPictureDrawer(
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val accountUserState by accountViewModel.account.userProfile().live().metadata.observeAsState()
|
||||
val profilePicture by accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState()
|
||||
|
||||
val pubkeyHex = remember { accountUserState?.user?.pubkeyHex ?: "" }
|
||||
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
|
||||
val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex }
|
||||
|
||||
IconButton(
|
||||
onClick = onClick
|
||||
@@ -536,8 +556,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
val followLists = _followLists.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
refreshFollows()
|
||||
}
|
||||
}
|
||||
@@ -682,6 +701,7 @@ fun FlexibleTopBarWithBackButton(
|
||||
},
|
||||
actions = {}
|
||||
)
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
Divider(thickness = 0.25.dp)
|
||||
}
|
||||
}
|
||||
@@ -732,9 +752,9 @@ fun debugState(context: Context) {
|
||||
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
|
||||
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
|
||||
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size)
|
||||
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
|
||||
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + " / " + LocalCache.notes.size)
|
||||
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + " / " + LocalCache.addressables.size)
|
||||
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + " / " + LocalCache.users.size)
|
||||
|
||||
Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB")
|
||||
|
||||
@@ -863,9 +883,7 @@ fun MyExtensibleTopAppBar(
|
||||
|
||||
if (expanded.value && extendableRow != null) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = Size10dp),
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Divider
|
||||
@@ -27,13 +29,19 @@ import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ModalBottomSheetState
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -46,14 +54,16 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
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.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.map
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
@@ -62,15 +72,20 @@ import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -95,8 +110,9 @@ fun DrawerContent(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 25.dp)
|
||||
.padding(top = 100.dp),
|
||||
.padding(top = 70.dp),
|
||||
scaffoldState,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
Divider(
|
||||
@@ -123,6 +139,7 @@ fun ProfileContent(
|
||||
baseAccountUser: User,
|
||||
modifier: Modifier = Modifier,
|
||||
scaffoldState: ScaffoldState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -146,7 +163,7 @@ fun ProfileContent(
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
.height(120.dp)
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
@@ -155,7 +172,7 @@ fun ProfileContent(
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
.height(120.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -204,7 +221,6 @@ fun ProfileContent(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(top = 15.dp)
|
||||
.clickable(
|
||||
onClick = {
|
||||
nav(route)
|
||||
@@ -215,9 +231,14 @@ fun ProfileContent(
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Row(Modifier.padding(top = Size10dp)) {
|
||||
EditStatusBox(baseAccountUser, accountViewModel)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 15.dp)
|
||||
.padding(top = Size10dp)
|
||||
.clickable(onClick = {
|
||||
nav(route)
|
||||
coroutineScope.launch {
|
||||
@@ -231,6 +252,139 @@ fun ProfileContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewModel) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LoadStatuses(user = baseAccountUser) { statuses ->
|
||||
if (statuses.isEmpty()) {
|
||||
val currentStatus = remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val hasChanged by remember {
|
||||
derivedStateOf {
|
||||
currentStatus.value != ""
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentStatus.value,
|
||||
onValueChange = { currentStatus.value = it },
|
||||
label = { Text(text = stringResource(R.string.status_update)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.status_update),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (hasChanged) {
|
||||
UserStatusSendButton() {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
statuses.forEach {
|
||||
val originalStatus by it.live().metadata.map {
|
||||
it.note.event?.content() ?: ""
|
||||
}.observeAsState(it.event?.content() ?: "")
|
||||
|
||||
val thisStatus = remember {
|
||||
mutableStateOf(originalStatus)
|
||||
}
|
||||
val hasChanged by remember {
|
||||
derivedStateOf {
|
||||
thisStatus.value != originalStatus
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = thisStatus.value,
|
||||
onValueChange = { thisStatus.value = it },
|
||||
label = { Text(text = stringResource(R.string.status_update)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.status_update),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
accountViewModel.updateStatus(it, thisStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (hasChanged) {
|
||||
UserStatusSendButton() {
|
||||
accountViewModel.updateStatus(it, thisStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
} else {
|
||||
UserStatusDeleteButton() {
|
||||
accountViewModel.deleteStatus(it)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserStatusSendButton(onClick: () -> Unit) {
|
||||
IconButton(
|
||||
modifier = Size26Modifier,
|
||||
onClick = onClick
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Send,
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserStatusDeleteButton(onClick: () -> Unit) {
|
||||
IconButton(
|
||||
modifier = Size26Modifier,
|
||||
onClick = onClick
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowingAndFollowerCounts(baseAccountUser: User) {
|
||||
var followingCount by remember { mutableStateOf("--") }
|
||||
@@ -301,7 +455,6 @@ fun ListContent(
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -337,7 +490,7 @@ fun ListContent(
|
||||
)
|
||||
|
||||
IconRowRelays(
|
||||
relayViewModel = relayViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
@@ -480,27 +633,37 @@ private fun enableTor(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayStatus(
|
||||
relayViewModel: RelayPoolViewModel
|
||||
) {
|
||||
val connectedRelaysText by relayViewModel.connectionStatus.observeAsState("--/--")
|
||||
val isConnected by relayViewModel.isConnected.distinctUntilChanged().observeAsState(false)
|
||||
private fun RelayStatus(accountViewModel: AccountViewModel) {
|
||||
val connectedRelaysText by RelayPool.statusFlow.collectAsState(initial = RelayPoolStatus(0, 0))
|
||||
|
||||
RenderRelayStatus(connectedRelaysText, isConnected)
|
||||
RenderRelayStatus(connectedRelaysText)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelayStatus(
|
||||
connectedRelaysText: String,
|
||||
isConnected: Boolean
|
||||
relayPool: RelayPoolStatus
|
||||
) {
|
||||
val text by remember(relayPool) {
|
||||
derivedStateOf {
|
||||
"${relayPool.connected}/${relayPool.available}"
|
||||
}
|
||||
}
|
||||
|
||||
val placeHolder = MaterialTheme.colors.placeholderText
|
||||
|
||||
val color by remember(relayPool) {
|
||||
derivedStateOf {
|
||||
if (relayPool.isConnected) {
|
||||
placeHolder
|
||||
} else {
|
||||
Color.Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = connectedRelaysText,
|
||||
color = if (isConnected) {
|
||||
MaterialTheme.colors.placeholderText
|
||||
} else {
|
||||
Color.Red
|
||||
},
|
||||
text = text,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.subtitle1
|
||||
)
|
||||
}
|
||||
@@ -556,7 +719,7 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
|
||||
fun IconRowRelays(accountViewModel: AccountViewModel, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -583,7 +746,7 @@ fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
|
||||
|
||||
Spacer(modifier = Modifier.width(Size16dp))
|
||||
|
||||
RelayStatus(relayViewModel = relayViewModel)
|
||||
RelayStatus(accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +120,12 @@ sealed class Route(
|
||||
)
|
||||
|
||||
object Room : Route(
|
||||
route = "Room/{id}",
|
||||
route = "Room/{id}?message={message}",
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
|
||||
arguments = listOf(
|
||||
navArgument("id") { type = NavType.StringType },
|
||||
navArgument("message") { type = NavType.StringType; nullable = true; defaultValue = null }
|
||||
).toImmutableList()
|
||||
)
|
||||
|
||||
object RoomByAuthor : Route(
|
||||
|
||||
@@ -95,7 +95,7 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -62,57 +62,63 @@ fun BlankNote(modifier: Modifier = Modifier, showDivider: Boolean = false, idHex
|
||||
@Composable
|
||||
fun HiddenNote(
|
||||
reports: ImmutableSet<Note>,
|
||||
isHiddenAuthor: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
isQuote: Boolean = false,
|
||||
nav: (String) -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
|
||||
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
start = 20.dp,
|
||||
end = 20.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.post_was_flagged_as_inappropriate_by),
|
||||
color = Color.Gray
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = if (!isQuote) 30.dp else 25.dp, end = 20.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(30.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.post_was_flagged_as_inappropriate_by),
|
||||
color = Color.Gray
|
||||
)
|
||||
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
|
||||
if (isHiddenAuthor) {
|
||||
UserPicture(
|
||||
user = accountViewModel.userProfile(),
|
||||
size = Size35dp,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
reports.forEach {
|
||||
NoteAuthorPicture(
|
||||
baseNote = it,
|
||||
size = Size35dp,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
|
||||
reports.forEach {
|
||||
NoteAuthorPicture(
|
||||
baseNote = it,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size35dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = onClick,
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_anyway), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = onClick,
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_anyway), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -58,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
@@ -74,7 +76,6 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -91,24 +92,10 @@ fun ChannelCardCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isBlank by baseNote.live().metadata.map {
|
||||
it.note.event == null
|
||||
}.distinctUntilChanged().observeAsState(baseNote.event == null)
|
||||
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
|
||||
|
||||
Crossfade(targetState = isBlank) {
|
||||
Crossfade(targetState = hasEvent) {
|
||||
if (it) {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = showPopup
|
||||
)
|
||||
},
|
||||
false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) {
|
||||
CheckHiddenChannelCardCompose(
|
||||
baseNote,
|
||||
@@ -120,6 +107,18 @@ fun ChannelCardCompose(
|
||||
nav
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = showPopup
|
||||
)
|
||||
},
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,13 +134,9 @@ fun CheckHiddenChannelCardCompose(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
if (showHidden) {
|
||||
var state by remember {
|
||||
val state by remember {
|
||||
mutableStateOf(
|
||||
NoteComposeReportState(
|
||||
isAcceptable = true,
|
||||
canPreview = true,
|
||||
relevantReports = persistentSetOf()
|
||||
)
|
||||
AccountViewModel.NoteComposeReportState()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,19 +180,14 @@ fun LoadedChannelCardCompose(
|
||||
) {
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
NoteComposeReportState(
|
||||
isAcceptable = true,
|
||||
canPreview = true,
|
||||
relevantReports = persistentSetOf()
|
||||
)
|
||||
AccountViewModel.NoteComposeReportState()
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
|
||||
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
|
||||
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
|
||||
WatchForReports(note, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
state = newState
|
||||
}
|
||||
@@ -219,7 +209,7 @@ fun LoadedChannelCardCompose(
|
||||
|
||||
@Composable
|
||||
fun RenderChannelCardReportState(
|
||||
state: NoteComposeReportState,
|
||||
state: AccountViewModel.NoteComposeReportState,
|
||||
note: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -233,6 +223,7 @@ fun RenderChannelCardReportState(
|
||||
if (showHiddenNote) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
state.isHiddenAuthor,
|
||||
accountViewModel,
|
||||
modifier,
|
||||
false,
|
||||
@@ -361,6 +352,10 @@ fun InnerChannelCardWithReactions(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -621,7 +616,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
|
||||
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
description?.let {
|
||||
@@ -735,7 +730,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
|
||||
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
description?.let {
|
||||
@@ -764,7 +759,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Gallery(users: List<User>, accountViewModel: AccountViewModel) {
|
||||
FlowRow(verticalAlignment = CenterVertically) {
|
||||
FlowRow(verticalArrangement = Arrangement.Center) {
|
||||
users.take(6).forEach {
|
||||
ClickableUserPicture(it, Size35dp, accountViewModel)
|
||||
}
|
||||
|
||||
@@ -77,14 +77,12 @@ fun ChatroomHeaderCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isBlank by baseNote.live().metadata.map {
|
||||
it.note.event == null
|
||||
}.observeAsState(baseNote.event == null)
|
||||
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
|
||||
|
||||
if (isBlank) {
|
||||
BlankNote(Modifier)
|
||||
} else {
|
||||
if (hasEvent) {
|
||||
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
|
||||
} else {
|
||||
BlankNote(Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,6 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -85,12 +83,20 @@ fun ChatroomMessageCompose(
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val isBlank by baseNote.live().metadata.map {
|
||||
it.note.event == null
|
||||
}.observeAsState(baseNote.event == null)
|
||||
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
|
||||
|
||||
Crossfade(targetState = isBlank) {
|
||||
Crossfade(targetState = hasEvent) {
|
||||
if (it) {
|
||||
CheckHiddenChatMessage(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
} else {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
@@ -101,16 +107,6 @@ fun ChatroomMessageCompose(
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CheckHiddenChatMessage(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,17 +150,13 @@ fun LoadedChatMessageCompose(
|
||||
) {
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
NoteComposeReportState(
|
||||
isAcceptable = true,
|
||||
canPreview = true,
|
||||
relevantReports = persistentSetOf()
|
||||
)
|
||||
AccountViewModel.NoteComposeReportState()
|
||||
)
|
||||
}
|
||||
|
||||
WatchForReports(baseNote, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
|
||||
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
|
||||
state = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
|
||||
WatchForReports(baseNote, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
state = newState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +172,7 @@ fun LoadedChatMessageCompose(
|
||||
if (it) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
state.isHiddenAuthor,
|
||||
accountViewModel,
|
||||
Modifier,
|
||||
innerQuote,
|
||||
@@ -568,7 +561,7 @@ private fun StatusRow(
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
|
||||
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
|
||||
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
ReplyReaction(
|
||||
baseNote = baseNote,
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -118,5 +120,9 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
|
||||
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -40,10 +41,7 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
|
||||
@@ -53,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
@@ -70,8 +68,6 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
|
||||
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -159,6 +155,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
|
||||
|
||||
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,8 +348,7 @@ private fun ParseAuthorCommentAndAmount(
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newState = loadAmountState(zapRequest, zapEvent, accountViewModel)
|
||||
accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState ->
|
||||
if (newState != null) {
|
||||
content.value = newState
|
||||
}
|
||||
@@ -359,34 +358,6 @@ private fun ParseAuthorCommentAndAmount(
|
||||
onReady(content)
|
||||
}
|
||||
|
||||
private suspend fun loadAmountState(
|
||||
zapRequest: Note,
|
||||
zapEvent: Note?,
|
||||
accountViewModel: AccountViewModel
|
||||
): ZapAmountCommentNotification? {
|
||||
(zapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = accountViewModel.decryptZap(zapRequest)
|
||||
val amount = (zapEvent?.event as? LnZapEvent)?.amount
|
||||
if (decryptedContent != null) {
|
||||
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
return ZapAmountCommentNotification(
|
||||
newAuthor,
|
||||
decryptedContent.content.ifBlank { null },
|
||||
showAmountAxis(amount)
|
||||
)
|
||||
} else {
|
||||
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
|
||||
return ZapAmountCommentNotification(
|
||||
zapRequest.author,
|
||||
zapRequest.event?.content()?.ifBlank { null },
|
||||
showAmountAxis(amount)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderState(
|
||||
content: MutableState<ZapAmountCommentNotification>,
|
||||
@@ -573,9 +544,7 @@ private fun WatchNoteAuthor(
|
||||
baseNote: Note,
|
||||
onContent: @Composable (User?) -> Unit
|
||||
) {
|
||||
val author by baseNote.live().metadata.map {
|
||||
it.note.author
|
||||
}.observeAsState(baseNote.author)
|
||||
val author by baseNote.live().authorChanges.observeAsState(baseNote.author)
|
||||
|
||||
onContent(author)
|
||||
}
|
||||
@@ -585,9 +554,7 @@ private fun WatchUserMetadata(
|
||||
author: User,
|
||||
onNewMetadata: @Composable (String?) -> Unit
|
||||
) {
|
||||
val userProfile by author.live().metadata.map {
|
||||
it.user.profilePicture()
|
||||
}.distinctUntilChanged().observeAsState(author.profilePicture())
|
||||
val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture())
|
||||
|
||||
onNewMetadata(userProfile)
|
||||
}
|
||||
|
||||
@@ -3,43 +3,62 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.map
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.Nip05Verifier
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
|
||||
import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification
|
||||
import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.nip05
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState<Boolean?> {
|
||||
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String, accountViewModel: AccountViewModel): MutableState<Boolean?> {
|
||||
val nip05Verified = remember(userMetadata.nip05) {
|
||||
// starts with null if must verify or already filled in if verified in the last hour
|
||||
val default = if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) {
|
||||
@@ -53,37 +72,9 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu
|
||||
|
||||
if (nip05Verified.value == null) {
|
||||
LaunchedEffect(key1 = userMetadata.nip05) {
|
||||
launch(Dispatchers.IO) {
|
||||
userMetadata.nip05?.ifBlank { null }?.let { nip05 ->
|
||||
Nip05Verifier().verifyNip05(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
// Marks user as verified
|
||||
if (it == pubkeyHex) {
|
||||
userMetadata.nip05Verified = true
|
||||
userMetadata.nip05LastVerificationTime = TimeUtils.now()
|
||||
|
||||
if (nip05Verified.value != true) {
|
||||
nip05Verified.value = true
|
||||
}
|
||||
} else {
|
||||
userMetadata.nip05Verified = false
|
||||
userMetadata.nip05LastVerificationTime = 0
|
||||
|
||||
if (nip05Verified.value != false) {
|
||||
nip05Verified.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
userMetadata.nip05LastVerificationTime = 0
|
||||
userMetadata.nip05Verified = false
|
||||
|
||||
if (nip05Verified.value != false) {
|
||||
nip05Verified.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus ->
|
||||
if (nip05Verified.value != newVerificationStatus) {
|
||||
nip05Verified.value = newVerificationStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,47 +84,196 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val author by remember(noteState) {
|
||||
derivedStateOf {
|
||||
noteState?.note?.author
|
||||
}
|
||||
}
|
||||
fun ObserveDisplayNip05Status(
|
||||
baseNote: Note,
|
||||
columnModifier: Modifier = Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val author by baseNote.live().authorChanges.observeAsState()
|
||||
|
||||
author?.let {
|
||||
ObserveDisplayNip05Status(it, columnModifier)
|
||||
ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
|
||||
val nip05 by baseUser.live().metadata.map {
|
||||
it.user.nip05()
|
||||
}.observeAsState(baseUser.nip05())
|
||||
fun ObserveDisplayNip05Status(
|
||||
baseUser: User,
|
||||
columnModifier: Modifier = Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05())
|
||||
|
||||
Crossfade(targetState = nip05, modifier = columnModifier) {
|
||||
if (it != null) {
|
||||
DisplayNIP05Line(it, baseUser, columnModifier)
|
||||
} else {
|
||||
Text(
|
||||
text = baseUser.pubkeyDisplayHex(),
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = columnModifier
|
||||
)
|
||||
LoadStatuses(baseUser) { statuses ->
|
||||
Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") {
|
||||
VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
|
||||
private fun VerifyAndDisplayNIP05OrStatusLine(
|
||||
nip05: String?,
|
||||
statuses: ImmutableList<AddressableNote>,
|
||||
baseUser: User,
|
||||
columnModifier: Modifier = Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column(modifier = columnModifier) {
|
||||
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
|
||||
Crossfade(targetState = nip05Verified) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DisplayNIP05(nip05, it)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05 != null) {
|
||||
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel)
|
||||
|
||||
if (nip05Verified.value != true) {
|
||||
DisplayNIP05(nip05, nip05Verified)
|
||||
} else if (!statuses.isEmpty()) {
|
||||
RotateStatuses(statuses, accountViewModel, nav)
|
||||
} else {
|
||||
DisplayNIP05(nip05, nip05Verified)
|
||||
}
|
||||
} else {
|
||||
if (!statuses.isEmpty()) {
|
||||
RotateStatuses(statuses, accountViewModel, nav)
|
||||
} else {
|
||||
DisplayUsersNpub(baseUser.pubkeyDisplayHex())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RotateStatuses(
|
||||
statuses: ImmutableList<AddressableNote>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var indexToDisplay by remember(statuses) {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
DisplayStatus(statuses[indexToDisplay], accountViewModel, nav)
|
||||
|
||||
if (statuses.size > 1) {
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(10.seconds)
|
||||
indexToDisplay = (indexToDisplay + 1) % statuses.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayUsersNpub(npub: String) {
|
||||
Text(
|
||||
text = npub,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayStatus(
|
||||
addressableNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteState by addressableNote.live().metadata.observeAsState()
|
||||
|
||||
val content = remember(noteState) { addressableNote.event?.content() ?: "" }
|
||||
val type = remember(noteState) {
|
||||
(addressableNote.event as? AddressableEvent)?.dTag() ?: ""
|
||||
}
|
||||
val url = remember(noteState) {
|
||||
addressableNote.event?.firstTaggedUrl()?.ifBlank { null }
|
||||
}
|
||||
val nostrATag = remember(noteState) {
|
||||
addressableNote.event?.firstTaggedAddress()
|
||||
}
|
||||
val nostrHexID = remember(noteState) {
|
||||
addressableNote.event?.firstTaggedEvent()?.ifBlank { null }
|
||||
}
|
||||
|
||||
when (type) {
|
||||
"music" -> Icon(
|
||||
painter = painterResource(id = R.drawable.tunestr),
|
||||
null,
|
||||
modifier = Size15Modifier.padding(end = Size5dp),
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = content,
|
||||
fontSize = Font14SP,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (url != null) {
|
||||
val uri = LocalUriHandler.current
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
IconButton(
|
||||
modifier = Size15Modifier,
|
||||
onClick = { runCatching { uri.openUri(url.trim()) } }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.OpenInNew,
|
||||
null,
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colors.lessImportantLink
|
||||
)
|
||||
}
|
||||
} else if (nostrATag != null) {
|
||||
LoadAddressableNote(nostrATag) { note ->
|
||||
if (note != null) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
IconButton(
|
||||
modifier = Size15Modifier,
|
||||
onClick = {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { nav(it) }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.OpenInNew,
|
||||
null,
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colors.lessImportantLink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (nostrHexID != null) {
|
||||
LoadNote(baseNoteHex = nostrHexID) {
|
||||
if (it != null) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
IconButton(
|
||||
modifier = Size15Modifier,
|
||||
onClick = {
|
||||
routeFor(
|
||||
it,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { nav(it) }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.OpenInNew,
|
||||
null,
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colors.lessImportantLink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +297,7 @@ private fun DisplayNIP05(
|
||||
if (user != "_") {
|
||||
Text(
|
||||
text = remember(nip05) { AnnotatedString(user) },
|
||||
fontSize = Font14SP,
|
||||
color = MaterialTheme.colors.nip05,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -168,7 +309,7 @@ private fun DisplayNIP05(
|
||||
ClickableText(
|
||||
text = remember(nip05) { AnnotatedString(domain) },
|
||||
onClick = { runCatching { uri.openUri("https://$domain") } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05),
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05, fontSize = Font14SP),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
@@ -186,12 +327,12 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>, modifier:
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayNip05ProfileStatus(user: User) {
|
||||
fun DisplayNip05ProfileStatus(user: User, accountViewModel: AccountViewModel) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
user.nip05()?.let { nip05 ->
|
||||
if (nip05.split("@").size <= 2) {
|
||||
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NIP05VerifiedSymbol(nip05Verified, Size16Modifier)
|
||||
var domainPadStart = 5.dp
|
||||
|
||||
@@ -23,9 +23,11 @@ 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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
@@ -36,7 +38,6 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
@@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
@@ -113,6 +115,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
@@ -121,9 +124,9 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
@@ -162,6 +165,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
@@ -192,7 +196,6 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -202,7 +205,6 @@ import java.io.File
|
||||
import java.math.BigDecimal
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -221,24 +223,10 @@ fun NoteCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isBlank by baseNote.live().metadata.map {
|
||||
it.note.event == null
|
||||
}.distinctUntilChanged().observeAsState(baseNote.event == null)
|
||||
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
|
||||
|
||||
Crossfade(targetState = isBlank) {
|
||||
Crossfade(targetState = hasEvent) {
|
||||
if (it) {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = showPopup
|
||||
)
|
||||
},
|
||||
isBoostedNote || isQuotedNote
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CheckHiddenNoteCompose(
|
||||
note = baseNote,
|
||||
routeForLastRead = routeForLastRead,
|
||||
@@ -253,6 +241,18 @@ fun NoteCompose(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
} else {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = showPopup
|
||||
)
|
||||
},
|
||||
isBoostedNote || isQuotedNote
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,11 +276,7 @@ fun CheckHiddenNoteCompose(
|
||||
// Ignores reports as well
|
||||
val state by remember {
|
||||
mutableStateOf(
|
||||
NoteComposeReportState(
|
||||
isAcceptable = true,
|
||||
canPreview = true,
|
||||
relevantReports = persistentSetOf()
|
||||
)
|
||||
AccountViewModel.NoteComposeReportState()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,13 +319,6 @@ fun CheckHiddenNoteCompose(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class NoteComposeReportState(
|
||||
val isAcceptable: Boolean,
|
||||
val canPreview: Boolean,
|
||||
val relevantReports: ImmutableSet<Note>
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LoadedNoteCompose(
|
||||
note: Note,
|
||||
@@ -346,19 +335,14 @@ fun LoadedNoteCompose(
|
||||
) {
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
NoteComposeReportState(
|
||||
isAcceptable = true,
|
||||
canPreview = true,
|
||||
relevantReports = persistentSetOf()
|
||||
)
|
||||
AccountViewModel.NoteComposeReportState()
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
|
||||
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
|
||||
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
|
||||
WatchForReports(note, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
state = newState
|
||||
}
|
||||
@@ -385,7 +369,7 @@ fun LoadedNoteCompose(
|
||||
|
||||
@Composable
|
||||
fun RenderReportState(
|
||||
state: NoteComposeReportState,
|
||||
state: AccountViewModel.NoteComposeReportState,
|
||||
note: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -404,6 +388,7 @@ fun RenderReportState(
|
||||
if (showHiddenNote) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
state.isHiddenAuthor,
|
||||
accountViewModel,
|
||||
modifier,
|
||||
isBoostedNote,
|
||||
@@ -435,15 +420,13 @@ fun RenderReportState(
|
||||
fun WatchForReports(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onChange: (Boolean, Boolean, ImmutableSet<Note>) -> Unit
|
||||
onChange: (AccountViewModel.NoteComposeReportState) -> Unit
|
||||
) {
|
||||
val userFollowsState by accountViewModel.userFollows.observeAsState()
|
||||
val noteReportsState by note.live().reports.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
accountViewModel.isNoteAcceptable(note, onChange)
|
||||
}
|
||||
accountViewModel.isNoteAcceptable(note, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,45 +445,86 @@ fun NormalNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
when (baseNote.event) {
|
||||
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
|
||||
channelNote = baseNote,
|
||||
showVideo = !makeItShort,
|
||||
showBottomDiviser = true,
|
||||
sendToChannel = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
|
||||
CommunityHeader(
|
||||
baseNote = it,
|
||||
if (isQuotedNote || isBoostedNote) {
|
||||
when (baseNote.event) {
|
||||
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
|
||||
channelNote = baseNote,
|
||||
showVideo = !makeItShort,
|
||||
showBottomDiviser = true,
|
||||
sendToCommunity = true,
|
||||
sendToChannel = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, isQuotedNote, accountViewModel)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, isQuotedNote, accountViewModel)
|
||||
else ->
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
isBoostedNote,
|
||||
isQuotedNote,
|
||||
unPackReply,
|
||||
makeItShort,
|
||||
addMarginTop,
|
||||
canPreview,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
showPopup,
|
||||
nav
|
||||
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
|
||||
CommunityHeader(
|
||||
baseNote = it,
|
||||
showBottomDiviser = true,
|
||||
sendToCommunity = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
|
||||
else ->
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
isBoostedNote,
|
||||
isQuotedNote,
|
||||
unPackReply,
|
||||
makeItShort,
|
||||
addMarginTop,
|
||||
canPreview,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
showPopup,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (baseNote.event) {
|
||||
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
|
||||
channelNote = baseNote,
|
||||
showVideo = !makeItShort,
|
||||
showBottomDiviser = true,
|
||||
sendToChannel = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
|
||||
CommunityHeader(
|
||||
baseNote = it,
|
||||
showBottomDiviser = true,
|
||||
sendToCommunity = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel)
|
||||
else ->
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
isBoostedNote,
|
||||
isQuotedNote,
|
||||
unPackReply,
|
||||
makeItShort,
|
||||
addMarginTop,
|
||||
canPreview,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
showPopup,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +542,7 @@ fun CommunityHeader(
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = modifier.clickable {
|
||||
modifier = Modifier.clickable {
|
||||
if (sendToCommunity) {
|
||||
routeFor(baseNote, accountViewModel.userProfile())?.let {
|
||||
nav(it)
|
||||
@@ -535,7 +559,14 @@ fun CommunityHeader(
|
||||
)
|
||||
|
||||
if (expanded.value) {
|
||||
LongCommunityHeader(baseNote, accountViewModel, nav)
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
LongCommunityHeader(
|
||||
baseNote = baseNote,
|
||||
lineModifier = modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,23 +579,41 @@ fun CommunityHeader(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
fun LongCommunityHeader(
|
||||
baseNote: AddressableNote,
|
||||
lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp),
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp)
|
||||
lineModifier
|
||||
) {
|
||||
val rulesLabel = stringResource(id = R.string.rules)
|
||||
val summary = remember(noteState) {
|
||||
noteEvent.description()?.ifBlank { null }
|
||||
val subject = noteEvent.subject()?.ifEmpty { null }
|
||||
val body = noteEvent.description()?.ifBlank { null }
|
||||
val rules = noteEvent.rules()?.ifBlank { null }
|
||||
|
||||
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
|
||||
if (rules == null) {
|
||||
"## $subject\n$body"
|
||||
} else {
|
||||
"## $subject\n$body\n\n## $rulesLabel\n\n$rules"
|
||||
}
|
||||
} else {
|
||||
if (rules == null) {
|
||||
body
|
||||
} else {
|
||||
"$body\n\n$rulesLabel\n$rules"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 10.dp)
|
||||
Modifier.weight(1f)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val defaultBackground = MaterialTheme.colors.background
|
||||
@@ -594,40 +643,8 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
|
||||
}
|
||||
}
|
||||
|
||||
val rules = remember(noteState) {
|
||||
noteEvent.rules()?.ifBlank { null }
|
||||
}
|
||||
|
||||
rules?.let {
|
||||
Spacer(DoubleVertSpacer)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp)
|
||||
) {
|
||||
val defaultBackground = MaterialTheme.colors.background
|
||||
val background = remember {
|
||||
mutableStateOf(defaultBackground)
|
||||
}
|
||||
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = false,
|
||||
tags = tags,
|
||||
backgroundColor = background,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(DoubleVertSpacer)
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp),
|
||||
lineModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
@@ -640,8 +657,6 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
|
||||
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
|
||||
Spacer(DoubleHorzSpacer)
|
||||
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
|
||||
TimeAgo(baseNote)
|
||||
MoreOptionsButton(baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
var participantUsers by remember(baseNote) {
|
||||
@@ -665,12 +680,9 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
|
||||
|
||||
participantUsers.forEach {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, top = 10.dp)
|
||||
.clickable {
|
||||
nav("User/${it.second.pubkeyHex}")
|
||||
},
|
||||
lineModifier.clickable {
|
||||
nav("User/${it.second.pubkeyHex}")
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
it.first.role?.let { it1 ->
|
||||
@@ -687,6 +699,21 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
|
||||
UsernameDisplay(it.second, remember { Modifier.weight(1f) })
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
lineModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.created_at),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.width(75.dp)
|
||||
)
|
||||
Spacer(DoubleHorzSpacer)
|
||||
NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f))
|
||||
MoreOptionsButton(baseNote, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -740,9 +767,9 @@ private fun ShortCommunityActionOptions(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
|
||||
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
|
||||
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
|
||||
|
||||
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
|
||||
if (!isFollowing) {
|
||||
@@ -897,7 +924,6 @@ fun ClickableNote(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun InnerNoteWithReactions(
|
||||
baseNote: Note,
|
||||
@@ -1009,7 +1035,7 @@ private fun NoteBody(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
|
||||
if (!makeItShort) {
|
||||
ReplyRow(
|
||||
@@ -1139,6 +1165,14 @@ private fun RenderNoteRow(
|
||||
)
|
||||
}
|
||||
|
||||
is FileHeaderEvent -> {
|
||||
FileHeaderDisplay(baseNote, true, accountViewModel)
|
||||
}
|
||||
|
||||
is FileStorageHeaderEvent -> {
|
||||
FileStorageHeaderDisplay(baseNote, true, accountViewModel)
|
||||
}
|
||||
|
||||
is CommunityPostApprovalEvent -> {
|
||||
RenderPostApproval(
|
||||
baseNote,
|
||||
@@ -1187,6 +1221,16 @@ fun routeFor(note: Note, loggedIn: User): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
fun routeToMessage(user: User, draftMessage: String?, accountViewModel: AccountViewModel): String {
|
||||
val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex))
|
||||
accountViewModel.account.userProfile().createChatroom(withKey)
|
||||
return if (draftMessage != null) {
|
||||
"Room/${withKey.hashCode()}?message=$draftMessage"
|
||||
} else {
|
||||
"Room/${withKey.hashCode()}"
|
||||
}
|
||||
}
|
||||
|
||||
fun routeFor(note: Channel): String {
|
||||
return "Channel/${note.idHex}"
|
||||
}
|
||||
@@ -1264,7 +1308,7 @@ fun RenderPoll(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteEvent = note.event as? PollNoteEvent ?: return
|
||||
val eventContent = remember { noteEvent.content() }
|
||||
val eventContent = remember(note) { noteEvent.content() }
|
||||
|
||||
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
|
||||
Text(
|
||||
@@ -1581,9 +1625,9 @@ fun DisplayRelaySet(
|
||||
) {
|
||||
val noteEvent = baseNote.event as? RelaySetEvent ?: return
|
||||
|
||||
val relays by remember {
|
||||
mutableStateOf<ImmutableList<String>>(
|
||||
noteEvent.relays().toImmutableList()
|
||||
val relays by remember(baseNote) {
|
||||
mutableStateOf(
|
||||
noteEvent.relays().map { RelayBriefInfo(it) }.toImmutableList()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1638,7 +1682,7 @@ fun DisplayRelaySet(
|
||||
toMembersShow.forEach { relay ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
|
||||
Text(
|
||||
relay.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/"),
|
||||
text = relay.displayUrl,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -1648,7 +1692,7 @@ fun DisplayRelaySet(
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
RelayOptionsAction(relay, accountViewModel, nav)
|
||||
RelayOptionsAction(relay.url, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2348,46 +2392,47 @@ private fun ReplyRow(
|
||||
) {
|
||||
val noteEvent = note.event
|
||||
|
||||
val showReply by remember {
|
||||
val showReply by remember(note) {
|
||||
derivedStateOf {
|
||||
noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
|
||||
noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
|
||||
}
|
||||
}
|
||||
|
||||
val showChannelInfo by remember(note) {
|
||||
derivedStateOf {
|
||||
if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) {
|
||||
note.channelHex()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showChannelInfo?.let {
|
||||
ChannelHeader(
|
||||
channelHex = it,
|
||||
showVideo = false,
|
||||
showBottomDiviser = false,
|
||||
sendToChannel = true,
|
||||
modifier = MaterialTheme.colors.replyModifier.padding(10.dp),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
if (showReply) {
|
||||
val replyingDirectlyTo = remember { note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } }
|
||||
if (replyingDirectlyTo != null && unPackReply) {
|
||||
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
} else {
|
||||
// ReplyInformation(note.replyTo, noteEvent.mentions(), accountViewModel, nav)
|
||||
}
|
||||
} else {
|
||||
val showChannelReply by remember {
|
||||
derivedStateOf {
|
||||
(noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) ||
|
||||
(noteEvent is LiveActivitiesChatMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()))
|
||||
} else if (showChannelInfo != null) {
|
||||
val replies = remember { note.replyTo?.toImmutableList() }
|
||||
val mentions = remember {
|
||||
(note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList()
|
||||
?: persistentListOf()
|
||||
}
|
||||
}
|
||||
|
||||
if (showChannelReply) {
|
||||
val channelHex = note.channelHex()
|
||||
channelHex?.let {
|
||||
ChannelHeader(
|
||||
channelHex = channelHex,
|
||||
showVideo = false,
|
||||
showBottomDiviser = false,
|
||||
sendToChannel = true,
|
||||
modifier = remember { Modifier.padding(vertical = 5.dp) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
val replies = remember { note.replyTo?.toImmutableList() }
|
||||
val mentions = remember { (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() }
|
||||
|
||||
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
|
||||
}
|
||||
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2436,7 +2481,7 @@ fun SecondUserInfoRow(
|
||||
val noteAuthor = remember { note.author } ?: return
|
||||
|
||||
Row(verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight) {
|
||||
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) })
|
||||
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav)
|
||||
|
||||
val geo = remember { noteEvent.getGeoHash() }
|
||||
if (geo != null) {
|
||||
@@ -2455,6 +2500,30 @@ fun SecondUserInfoRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadStatuses(
|
||||
user: User,
|
||||
content: @Composable (ImmutableList<AddressableNote>) -> Unit
|
||||
) {
|
||||
var statuses: ImmutableList<AddressableNote> by remember {
|
||||
mutableStateOf(persistentListOf())
|
||||
}
|
||||
|
||||
val userStatus by user.live().statuses.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = userStatus) {
|
||||
launch(Dispatchers.IO) {
|
||||
val myUser = userStatus?.user ?: return@launch
|
||||
val newStatuses = LocalCache.findStatusesForUser(myUser)
|
||||
if (!equalImmutableLists(statuses, newStatuses)) {
|
||||
statuses = newStatuses
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content(statuses)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
@@ -2596,14 +2665,14 @@ private fun BadgeBox(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isRepost by remember {
|
||||
val isRepost by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
|
||||
}
|
||||
}
|
||||
|
||||
if (isRepost) {
|
||||
val baseReply by remember {
|
||||
val baseReply by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
baseNote.replyTo?.lastOrNull()
|
||||
}
|
||||
@@ -3194,7 +3263,7 @@ private fun RenderBadge(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) {
|
||||
fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
val fullUrl = event.url() ?: return
|
||||
|
||||
@@ -3230,18 +3299,18 @@ fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: Accou
|
||||
}
|
||||
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
ZoomableContentView(content = content, roundedCorner = isQuotedNote, accountViewModel = accountViewModel)
|
||||
ZoomableContentView(content = content, roundedCorner = roundedCorner, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) {
|
||||
fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
|
||||
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
|
||||
val dataEventId = eventHeader.dataEventId() ?: return
|
||||
|
||||
LoadNote(baseNoteHex = dataEventId) { contentNote ->
|
||||
if (contentNote != null) {
|
||||
ObserverAndRenderNIP95(baseNote, contentNote, isQuotedNote, accountViewModel)
|
||||
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3250,7 +3319,7 @@ fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewM
|
||||
private fun ObserverAndRenderNIP95(
|
||||
header: Note,
|
||||
content: Note,
|
||||
isQuotedNote: Boolean,
|
||||
roundedCorner: Boolean,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
|
||||
@@ -3297,7 +3366,7 @@ private fun ObserverAndRenderNIP95(
|
||||
Crossfade(targetState = content) {
|
||||
if (it != null) {
|
||||
SensitivityWarning(note = header, accountViewModel = accountViewModel) {
|
||||
ZoomableContentView(content = it, roundedCorner = isQuotedNote, accountViewModel = accountViewModel)
|
||||
ZoomableContentView(content = it, roundedCorner = roundedCorner, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3426,7 +3495,12 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
|
||||
}
|
||||
|
||||
content?.let {
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 5.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
|
||||
@@ -98,9 +98,7 @@ private fun WatchZapsAndUpdateTallies(
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
pollViewModel.refreshTallies()
|
||||
}
|
||||
pollViewModel.refreshTallies()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +130,7 @@ private fun OptionNote(
|
||||
ZapVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
pollViewModel = pollViewModel,
|
||||
nonClickablePrepend = {
|
||||
RenderOptionAfterVote(
|
||||
poolOption.descriptor,
|
||||
@@ -147,18 +144,21 @@ private fun OptionNote(
|
||||
)
|
||||
},
|
||||
clickablePrepend = {
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
} else {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
pollViewModel = pollViewModel,
|
||||
nonClickablePrepend = {},
|
||||
clickablePrepend = {
|
||||
RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -269,11 +269,12 @@ private fun RenderOptionBeforeVote(
|
||||
fun ZapVote(
|
||||
baseNote: Note,
|
||||
poolOption: PollOption,
|
||||
accountViewModel: AccountViewModel,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
nonClickablePrepend: @Composable () -> Unit,
|
||||
clickablePrepend: @Composable () -> Unit
|
||||
clickablePrepend: @Composable () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isLoggedUser by remember {
|
||||
derivedStateOf {
|
||||
@@ -283,6 +284,7 @@ fun ZapVote(
|
||||
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -384,7 +386,7 @@ fun ZapVote(
|
||||
onError = {
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
@@ -395,6 +397,19 @@ fun ZapVote(
|
||||
)
|
||||
}
|
||||
|
||||
if (showErrorMessageDialog != null) {
|
||||
ErrorMessageDialog(
|
||||
title = stringResource(id = R.string.error_dialog_zap_error),
|
||||
textContent = showErrorMessageDialog ?: "",
|
||||
onClickStartMessage = {
|
||||
baseNote.author?.let {
|
||||
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
|
||||
}
|
||||
},
|
||||
onDismiss = { showErrorMessageDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
clickablePrepend()
|
||||
|
||||
if (poolOption.zappedByLoggedIn) {
|
||||
|
||||
@@ -3,13 +3,16 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
@@ -52,12 +55,12 @@ class PollNoteViewModel : ViewModel() {
|
||||
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
||||
}
|
||||
|
||||
suspend fun refreshTallies() {
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
fun refreshTallies() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
|
||||
_tallies.emit(
|
||||
pollOptions?.keys?.map {
|
||||
val newOptions = pollOptions?.keys?.map {
|
||||
val zappedInOption = zappedPollOptionAmount(it)
|
||||
|
||||
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||
@@ -71,8 +74,12 @@ class PollNoteViewModel : ViewModel() {
|
||||
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
|
||||
|
||||
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
|
||||
} ?: emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
_tallies.emit(
|
||||
newOptions ?: emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun canZap(): Boolean {
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material.ProgressIndicatorDefaults
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -57,6 +58,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -114,7 +116,6 @@ import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.DecimalFormat
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Composable
|
||||
@@ -141,7 +142,6 @@ fun ReactionsRow(
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun InnerReactionRow(
|
||||
baseNote: Note,
|
||||
@@ -211,7 +211,7 @@ private fun InnerReactionRow(
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
|
||||
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
@@ -242,7 +242,7 @@ private fun LoadAndDisplayZapraiser(
|
||||
wantsToSeeReactions: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val zapraiserAmount by remember {
|
||||
val zapraiserAmount by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
baseNote.event?.zapraiserAmount() ?: 0
|
||||
}
|
||||
@@ -262,42 +262,26 @@ private fun LoadAndDisplayZapraiser(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ZapraiserStatus(val progress: Float, val left: String)
|
||||
|
||||
@Composable
|
||||
fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
var zapraiserProgress by remember { mutableStateOf(0F) }
|
||||
var zapraiserLeft by remember { mutableStateOf("$zapraiserAmount") }
|
||||
var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
zapsState?.note?.let {
|
||||
val newZapAmount = accountViewModel.calculateZapAmount(it)
|
||||
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
|
||||
|
||||
if (percentage > 1) {
|
||||
percentage = 1f
|
||||
}
|
||||
|
||||
if (Math.abs(zapraiserProgress - percentage) > 0.001) {
|
||||
val newZapraiserProgress = percentage
|
||||
val newZapraiserLeft = if (percentage > 0.99) {
|
||||
"0"
|
||||
} else {
|
||||
showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
|
||||
}
|
||||
if (zapraiserLeft != newZapraiserLeft) {
|
||||
zapraiserLeft = newZapraiserLeft
|
||||
}
|
||||
if (zapraiserProgress != newZapraiserProgress) {
|
||||
zapraiserProgress = newZapraiserProgress
|
||||
}
|
||||
zapsState?.note?.let {
|
||||
accountViewModel.calculateZapraiser(baseNote) { newStatus ->
|
||||
if (zapraiserStatus != newStatus) {
|
||||
zapraiserStatus = newStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val color = if (zapraiserProgress > 0.99) {
|
||||
val color = if (zapraiserStatus.progress > 0.99) {
|
||||
DarkerGreen
|
||||
} else {
|
||||
MaterialTheme.colors.mediumImportanceLink
|
||||
@@ -308,7 +292,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
|
||||
.fillMaxWidth()
|
||||
.height(if (details) 24.dp else 4.dp),
|
||||
color = color,
|
||||
progress = zapraiserProgress
|
||||
progress = zapraiserStatus.progress
|
||||
)
|
||||
|
||||
if (details) {
|
||||
@@ -316,14 +300,14 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
|
||||
contentAlignment = Center,
|
||||
modifier = TinyBorders
|
||||
) {
|
||||
val totalPercentage by remember(zapraiserProgress) {
|
||||
val totalPercentage by remember(zapraiserStatus) {
|
||||
derivedStateOf {
|
||||
"${(zapraiserProgress * 100).roundToInt()}%"
|
||||
"${(zapraiserStatus.progress * 100).roundToInt()}%"
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
|
||||
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left),
|
||||
modifier = NoSoTinyBorders,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
fontSize = Font14SP,
|
||||
@@ -335,15 +319,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
|
||||
|
||||
@Composable
|
||||
private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) {
|
||||
val hasReactions by baseNote.live().zaps.combineWith(
|
||||
liveData1 = baseNote.live().boosts,
|
||||
liveData2 = baseNote.live().reactions,
|
||||
block = { zapsState, boostsState, reactionsState ->
|
||||
zapsState?.note?.zaps?.isNotEmpty() == true ||
|
||||
boostsState?.note?.boosts?.isNotEmpty() == true ||
|
||||
reactionsState?.note?.reactions?.isNotEmpty() == true
|
||||
}
|
||||
).observeAsState(
|
||||
val hasReactions by baseNote.live().hasReactions.observeAsState(
|
||||
baseNote.zaps.isNotEmpty() ||
|
||||
baseNote.boosts.isNotEmpty() ||
|
||||
baseNote.reactions.isNotEmpty()
|
||||
@@ -415,14 +391,7 @@ private fun ReactionDetailGallery(
|
||||
val defaultBackgroundColor = MaterialTheme.colors.background
|
||||
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
|
||||
|
||||
val hasReactions by baseNote.live().zaps.combineWith(
|
||||
baseNote.live().boosts,
|
||||
baseNote.live().reactions
|
||||
) { zapState, boostState, reactionState ->
|
||||
zapState?.note?.zaps?.isNotEmpty() ?: false ||
|
||||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
|
||||
reactionState?.note?.reactions?.isNotEmpty() ?: false
|
||||
}.distinctUntilChanged().observeAsState(
|
||||
val hasReactions by baseNote.live().hasReactions.observeAsState(
|
||||
baseNote.zaps.isNotEmpty() || baseNote.boosts.isNotEmpty() || baseNote.reactions.isNotEmpty()
|
||||
)
|
||||
|
||||
@@ -443,17 +412,16 @@ private fun WatchBoostsAndRenderGallery(
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val boostsState by baseNote.live().boosts.observeAsState()
|
||||
val boostsEvents by remember(boostsState) {
|
||||
derivedStateOf { baseNote.boosts.toImmutableList() }
|
||||
}
|
||||
val boostsEvents by baseNote.live().boostList.observeAsState()
|
||||
|
||||
if (boostsEvents.isNotEmpty()) {
|
||||
RenderBoostGallery(
|
||||
boostsEvents,
|
||||
nav,
|
||||
accountViewModel
|
||||
)
|
||||
boostsEvents?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
RenderBoostGallery(
|
||||
it,
|
||||
nav,
|
||||
accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,9 +563,7 @@ fun ReplyReaction(
|
||||
|
||||
@Composable
|
||||
fun ReplyCounter(baseNote: Note, textColor: Color) {
|
||||
val repliesState by baseNote.live().replies.map {
|
||||
it.note.replies.size
|
||||
}.observeAsState(baseNote.replies.size)
|
||||
val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size)
|
||||
|
||||
SlidingAnimationCount(repliesState, textColor)
|
||||
}
|
||||
@@ -612,14 +578,14 @@ private fun SlidingAnimationCount(baseCount: MutableState<Int>, textColor: Color
|
||||
private fun SlidingAnimationCount(baseCount: Int, textColor: Color) {
|
||||
AnimatedContent<Int>(
|
||||
targetState = baseCount,
|
||||
transitionSpec = AnimatedContentScope<Int>::transitionSpec
|
||||
transitionSpec = AnimatedContentTransitionScope<Int>::transitionSpec
|
||||
) { count ->
|
||||
TextCount(count, textColor)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun <S> AnimatedContentScope<S>.transitionSpec(): ContentTransform {
|
||||
private fun <S> AnimatedContentTransitionScope<S>.transitionSpec(): ContentTransform {
|
||||
return slideAnimation
|
||||
}
|
||||
|
||||
@@ -646,7 +612,7 @@ private fun TextCount(count: Int, textColor: Color) {
|
||||
private fun SlidingAnimationAmount(amount: MutableState<String>, textColor: Color) {
|
||||
AnimatedContent(
|
||||
targetState = amount.value,
|
||||
transitionSpec = AnimatedContentScope<String>::transitionSpec
|
||||
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec
|
||||
) { count ->
|
||||
Text(
|
||||
text = count,
|
||||
@@ -697,20 +663,21 @@ fun BoostReaction(
|
||||
}
|
||||
) {
|
||||
BoostIcon(baseNote, iconSize, grayTint, accountViewModel)
|
||||
}
|
||||
|
||||
if (wantsToBoost) {
|
||||
BoostTypeChoicePopup(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
onDismiss = {
|
||||
wantsToBoost = false
|
||||
},
|
||||
onQuote = {
|
||||
wantsToBoost = false
|
||||
onQuotePress()
|
||||
}
|
||||
)
|
||||
if (wantsToBoost) {
|
||||
BoostTypeChoicePopup(
|
||||
baseNote,
|
||||
iconSize,
|
||||
accountViewModel,
|
||||
onDismiss = {
|
||||
wantsToBoost = false
|
||||
},
|
||||
onQuote = {
|
||||
wantsToBoost = false
|
||||
onQuotePress()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BoostText(baseNote, grayTint)
|
||||
@@ -731,9 +698,7 @@ fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountV
|
||||
|
||||
@Composable
|
||||
fun BoostText(baseNote: Note, grayTint: Color) {
|
||||
val boostState by baseNote.live().boosts.map {
|
||||
it.note.boosts.size
|
||||
}.distinctUntilChanged().observeAsState(baseNote.boosts.size)
|
||||
val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size)
|
||||
|
||||
SlidingAnimationCount(boostState, grayTint)
|
||||
}
|
||||
@@ -797,6 +762,7 @@ fun LikeReaction(
|
||||
if (wantsToReact) {
|
||||
ReactionChoicePopup(
|
||||
baseNote,
|
||||
iconSize,
|
||||
accountViewModel,
|
||||
onDismiss = {
|
||||
wantsToReact = false
|
||||
@@ -846,10 +812,7 @@ private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountVi
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = reactionsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
val reactionNote = reactionsState?.note?.getReactionBy(accountViewModel.userProfile())
|
||||
onNewReactionType(reactionNote)
|
||||
}
|
||||
accountViewModel.loadReactionTo(reactionsState?.note, onNewReactionType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,32 +847,9 @@ private fun RenderReactionType(
|
||||
|
||||
@Composable
|
||||
fun LikeText(baseNote: Note, grayTint: Color) {
|
||||
val reactionsCount = remember(baseNote) {
|
||||
mutableStateOf(baseNote.reactions.size)
|
||||
}
|
||||
val reactionCount by baseNote.live().reactionCount.observeAsState(0)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchReactionCountForNote(baseNote) { newReactionsCount ->
|
||||
if (reactionsCount.value != newReactionsCount) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
reactionsCount.value = newReactionsCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SlidingAnimationCount(reactionsCount, grayTint)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchReactionCountForNote(baseNote: Note, onNewReactionCount: (Int) -> Unit) {
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = reactionsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
onNewReactionCount(reactionsState?.note?.countReactions() ?: 0)
|
||||
}
|
||||
}
|
||||
SlidingAnimationCount(reactionCount, grayTint)
|
||||
}
|
||||
|
||||
private fun likeClick(
|
||||
@@ -960,11 +900,13 @@ fun ZapReaction(
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
iconSize: Dp = 20.dp,
|
||||
animationSize: Dp = 14.dp
|
||||
animationSize: Dp = 14.dp,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
|
||||
var wantsToSetCustomZap by remember { mutableStateOf(false) }
|
||||
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -990,6 +932,12 @@ fun ZapReaction(
|
||||
},
|
||||
onMultipleChoices = {
|
||||
wantsToZap = true
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -1016,7 +964,7 @@ fun ZapReaction(
|
||||
onError = {
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
@@ -1027,12 +975,43 @@ fun ZapReaction(
|
||||
)
|
||||
}
|
||||
|
||||
if (showErrorMessageDialog != null) {
|
||||
ErrorMessageDialog(
|
||||
title = stringResource(id = R.string.error_dialog_zap_error),
|
||||
textContent = showErrorMessageDialog ?: "",
|
||||
onClickStartMessage = {
|
||||
baseNote.author?.let {
|
||||
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
|
||||
}
|
||||
},
|
||||
onDismiss = { showErrorMessageDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
if (wantsToChangeZapAmount) {
|
||||
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
|
||||
UpdateZapAmountDialog(
|
||||
onClose = { wantsToChangeZapAmount = false },
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (wantsToSetCustomZap) {
|
||||
ZapCustomDialog({ wantsToSetCustomZap = false }, accountViewModel, baseNote)
|
||||
ZapCustomDialog(
|
||||
onClose = { wantsToSetCustomZap = false },
|
||||
onError = {
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
baseNote = baseNote
|
||||
)
|
||||
}
|
||||
|
||||
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
|
||||
@@ -1065,7 +1044,8 @@ private fun zapClick(
|
||||
scope: CoroutineScope,
|
||||
context: Context,
|
||||
onZappingProgress: (Float) -> Unit,
|
||||
onMultipleChoices: () -> Unit
|
||||
onMultipleChoices: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
if (accountViewModel.account.zapAmountChoices.isEmpty()) {
|
||||
scope.launch {
|
||||
@@ -1094,14 +1074,7 @@ private fun zapClick(
|
||||
null,
|
||||
"",
|
||||
context,
|
||||
onError = {
|
||||
scope.launch {
|
||||
onZappingProgress(0f)
|
||||
Toast
|
||||
.makeText(context, it, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
onZappingProgress(it)
|
||||
@@ -1149,9 +1122,7 @@ private fun WatchZapsForNote(baseNote: Note, accountViewModel: AccountViewModel,
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
onWasZapped(accountViewModel.calculateIfNoteWasZappedByAccount(baseNote))
|
||||
}
|
||||
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, onWasZapped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1181,9 +1152,7 @@ fun WatchZapAmountsForNote(baseNote: Note, accountViewModel: AccountViewModel, o
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
onZapAmount(showAmount(accountViewModel.calculateZapAmount(baseNote)))
|
||||
}
|
||||
accountViewModel.calculateZapAmount(baseNote, onZapAmount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1227,10 +1196,14 @@ private fun DrawViewCount(
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
|
||||
private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
|
||||
val iconSizePx = with(LocalDensity.current) {
|
||||
-iconSize.toPx().toInt()
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
offset = IntOffset(0, -50),
|
||||
offset = IntOffset(0, iconSizePx),
|
||||
onDismissRequest = { onDismiss() }
|
||||
) {
|
||||
FlowRow {
|
||||
@@ -1271,6 +1244,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
|
||||
@Composable
|
||||
fun ReactionChoicePopup(
|
||||
baseNote: Note,
|
||||
iconSize: Dp,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onChangeAmount: () -> Unit
|
||||
@@ -1282,9 +1256,13 @@ fun ReactionChoicePopup(
|
||||
baseNote.reactedBy(account.userProfile()).toSet()
|
||||
}
|
||||
|
||||
val iconSizePx = with(LocalDensity.current) {
|
||||
-iconSize.toPx().toInt()
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
offset = IntOffset(0, -50),
|
||||
offset = IntOffset(0, iconSizePx),
|
||||
onDismissRequest = { onDismiss() }
|
||||
) {
|
||||
FlowRow(horizontalArrangement = Arrangement.Center) {
|
||||
|
||||
@@ -11,17 +11,16 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
|
||||
@@ -29,80 +28,40 @@ import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconButtonModifie
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
public fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showShowMore by remember { mutableStateOf(false) }
|
||||
|
||||
var lazyRelayList by remember {
|
||||
val baseNumber = baseNote.relays.map {
|
||||
it.removePrefix("wss://").removePrefix("ws://")
|
||||
}.toImmutableList()
|
||||
|
||||
mutableStateOf(baseNumber)
|
||||
}
|
||||
var shortRelayList by remember {
|
||||
mutableStateOf(lazyRelayList.take(3).toImmutableList())
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchRelayLists(baseNote) { relayList ->
|
||||
if (!equalImmutableLists(relayList, lazyRelayList)) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
lazyRelayList = relayList
|
||||
shortRelayList = relayList.take(3).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
val nextShowMore = relayList.size > 3
|
||||
if (nextShowMore != showShowMore) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// only triggers recomposition when actually different
|
||||
showShowMore = nextShowMore
|
||||
}
|
||||
val relayList by baseNote.live().relayInfo.observeAsState(persistentListOf())
|
||||
val shortRelayList by remember {
|
||||
derivedStateOf {
|
||||
relayList.take(3).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(DoubleVertSpacer)
|
||||
|
||||
if (expanded) {
|
||||
VerticalRelayPanelWithFlow(lazyRelayList, accountViewModel, nav)
|
||||
VerticalRelayPanelWithFlow(relayList, accountViewModel, nav)
|
||||
} else {
|
||||
VerticalRelayPanelWithFlow(shortRelayList, accountViewModel, nav)
|
||||
}
|
||||
|
||||
if (showShowMore && !expanded) {
|
||||
if (relayList.size > 3 && !expanded) {
|
||||
ShowMoreRelaysButton {
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchRelayLists(baseNote: Note, onListChanges: (ImmutableList<String>) -> Unit) {
|
||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = noteRelaysState) {
|
||||
launch(Dispatchers.IO) {
|
||||
val relayList = noteRelaysState?.note?.relays?.map {
|
||||
it.removePrefix("wss://").removePrefix("ws://")
|
||||
} ?: emptyList()
|
||||
|
||||
onListChanges(relayList.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@Stable
|
||||
private fun VerticalRelayPanelWithFlow(
|
||||
relays: ImmutableList<String>,
|
||||
relays: ImmutableList<RelayBriefInfo>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -17,7 +18,6 @@ import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -29,13 +29,15 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
@@ -44,6 +46,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
@@ -59,15 +63,13 @@ public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewMo
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RenderRelayList(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteRelays by baseNote.live().relays.map {
|
||||
it.note.relays
|
||||
}.observeAsState(baseNote.relays)
|
||||
val noteRelays by baseNote.live().relayInfo.observeAsState()
|
||||
|
||||
FlowRow(StdStartPadding) {
|
||||
val relaysToDisplay = remember(noteRelays, expanded.value) {
|
||||
if (expanded.value) noteRelays else noteRelays.take(3)
|
||||
if (expanded.value) noteRelays else noteRelays?.take(3)?.toImmutableList()
|
||||
}
|
||||
relaysToDisplay.forEach {
|
||||
relaysToDisplay?.forEach {
|
||||
RenderRelay(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
@@ -104,14 +106,7 @@ fun ChatRelayExpandButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val iconUrl by remember(dirtyUrl) {
|
||||
derivedStateOf {
|
||||
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
|
||||
"https://$cleanUrl/favicon.ico"
|
||||
}
|
||||
}
|
||||
|
||||
fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
|
||||
|
||||
if (relayInfo != null) {
|
||||
@@ -120,8 +115,9 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
relayInfo = null
|
||||
},
|
||||
relayInfo = relayInfo!!,
|
||||
accountViewModel,
|
||||
nav
|
||||
relayBriefInfo = relay,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,7 +126,7 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val ripple = rememberRipple(bounded = false, radius = Size15dp)
|
||||
|
||||
val clickableModifier = remember(dirtyUrl) {
|
||||
val clickableModifier = remember(relay) {
|
||||
Modifier
|
||||
.padding(1.dp)
|
||||
.size(Size15dp)
|
||||
@@ -139,9 +135,30 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple,
|
||||
onClick = {
|
||||
loadRelayInfo(dirtyUrl, context, scope) {
|
||||
relayInfo = it
|
||||
}
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
relay.url,
|
||||
onInfo = {
|
||||
relayInfo = it
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg = when (errorCode) {
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> context.getString(R.string.relay_information_document_error_assemble_url, url, exceptionMessage)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
msg,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -149,17 +166,17 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
Box(
|
||||
modifier = clickableModifier
|
||||
) {
|
||||
RenderRelayIcon(iconUrl)
|
||||
RenderRelayIcon(relay.favIcon)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelayIcon(iconUrl: String) {
|
||||
fun RenderRelayIcon(iconUrl: String, size: Dp = Size13dp) {
|
||||
val backgroundColor = MaterialTheme.colors.background
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier
|
||||
.size(Size13dp)
|
||||
.size(size)
|
||||
.clip(shape = CircleShape)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ fun UpdateReactionTypeDialog(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -197,7 +197,11 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) {
|
||||
fun UpdateZapAmountDialog(
|
||||
onClose: () -> Unit,
|
||||
nip47uri: String? = null,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -250,7 +254,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
@@ -13,18 +13,12 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
|
||||
@Composable
|
||||
fun UserCompose(
|
||||
baseUser: User,
|
||||
overallModifier: Modifier = remember {
|
||||
Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
},
|
||||
overallModifier: Modifier = StdPadding,
|
||||
showDiviser: Boolean = true,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
@@ -56,7 +50,6 @@ fun UserCompose(
|
||||
|
||||
if (showDiviser) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,9 +73,7 @@ fun NoteAuthorPicture(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: ((User) -> Unit)? = null
|
||||
) {
|
||||
val author by baseNote.live().metadata.map {
|
||||
it.note.author
|
||||
}.distinctUntilChanged().observeAsState(baseNote.author)
|
||||
val author by baseNote.live().authorChanges.observeAsState(baseNote.author)
|
||||
|
||||
Crossfade(targetState = author) {
|
||||
if (it == null) {
|
||||
@@ -112,23 +110,19 @@ fun UserPicture(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val route by remember {
|
||||
val route by remember(user) {
|
||||
derivedStateOf {
|
||||
"User/${user.pubkeyHex}"
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ClickableUserPicture(
|
||||
baseUser = user,
|
||||
size = size,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = pictureModifier,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
nav(route)
|
||||
}
|
||||
nav(route)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -274,9 +268,7 @@ fun InnerBaseUserPicture(
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val userProfile by baseUser.live().metadata.map {
|
||||
it.user.profilePicture()
|
||||
}.distinctUntilChanged().observeAsState(baseUser.profilePicture())
|
||||
val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture())
|
||||
|
||||
PictureAndFollowingMark(
|
||||
userHex = baseUser.pubkeyHex,
|
||||
@@ -508,9 +500,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
fun WatchBookmarksFollowsAndAccount(note: Note, accountViewModel: AccountViewModel, onNew: (DropDownParams) -> Unit) {
|
||||
val followState by accountViewModel.userProfile().live().follows.observeAsState()
|
||||
val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
|
||||
val showSensitiveContent by accountViewModel.accountLiveData.map {
|
||||
it.account.showSensitiveContent
|
||||
}.distinctUntilChanged().observeAsState(accountViewModel.account.showSensitiveContent)
|
||||
val showSensitiveContent by accountViewModel.showSensitiveContentChanges.observeAsState(accountViewModel.account.showSensitiveContent)
|
||||
|
||||
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
|
||||
launch(Dispatchers.IO) {
|
||||
|
||||
@@ -57,6 +57,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
@@ -109,26 +111,58 @@ fun UserReactionsRow(
|
||||
|
||||
@Composable
|
||||
private fun UserZapModel(model: UserReactionsViewModel) {
|
||||
val zaps by model.zaps.collectAsState()
|
||||
UserZapReaction(showAmountAxis(zaps[model.today()]))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Size24Modifier,
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
UserZapReaction(model)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserReactionModel(model: UserReactionsViewModel) {
|
||||
val reactions by model.reactions.collectAsState()
|
||||
UserLikeReaction(reactions[model.today()])
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
UserLikeReaction(model)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserBoostModel(model: UserReactionsViewModel) {
|
||||
val boosts by model.boosts.collectAsState()
|
||||
UserBoostReaction(boosts[model.today()])
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Size24Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
UserBoostReaction(model)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserReplyModel(model: UserReactionsViewModel) {
|
||||
val replies by model.replies.collectAsState()
|
||||
UserReplyReaction(replies[model.today()])
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = RoyalBlue
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
UserReplyReaction(model)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -154,6 +188,11 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
private var takenIntoAccount = setOf<HexKey>()
|
||||
private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
|
||||
|
||||
val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged()
|
||||
val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged()
|
||||
val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged()
|
||||
val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged()
|
||||
|
||||
fun formatDate(createAt: Long): String {
|
||||
return sdf.format(
|
||||
Instant.ofEpochSecond(createAt)
|
||||
@@ -191,7 +230,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
}
|
||||
} else if (noteEvent is LnZapEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
|
||||
if (noteEvent.isTaggedUser(currentUser)) { // the user might be sending his own receipts noteEvent.pubKey != currentUser
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
@@ -245,7 +284,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
hasNewElements = true
|
||||
}
|
||||
} else if (noteEvent is LnZapEvent) {
|
||||
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
|
||||
if (noteEvent.isTaggedUser(currentUser)) { // && noteEvent.pubKey != currentUser User might be sending his own receipts
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
|
||||
takenIntoAccount.add(noteEvent.id())
|
||||
@@ -326,6 +365,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
collectorJob?.cancel()
|
||||
bundlerInsert.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@@ -338,18 +378,9 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
|
||||
@Composable
|
||||
fun UserReplyReaction(
|
||||
replyCount: Int?
|
||||
model: UserReactionsViewModel
|
||||
) {
|
||||
val showCounts = remember(replyCount) { showCount(replyCount) }
|
||||
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = RoyalBlue
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
val showCounts by model.todaysReplyCount.collectAsState("")
|
||||
|
||||
Text(
|
||||
showCounts,
|
||||
@@ -360,21 +391,12 @@ fun UserReplyReaction(
|
||||
|
||||
@Composable
|
||||
fun UserBoostReaction(
|
||||
boostCount: Int?
|
||||
model: UserReactionsViewModel
|
||||
) {
|
||||
val showCounts = remember(boostCount) { showCount(boostCount) }
|
||||
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Size24Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
val boosts by model.todaysBoostCount.collectAsState("")
|
||||
|
||||
Text(
|
||||
showCounts,
|
||||
boosts,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
@@ -382,21 +404,12 @@ fun UserBoostReaction(
|
||||
|
||||
@Composable
|
||||
fun UserLikeReaction(
|
||||
likeCount: Int?
|
||||
model: UserReactionsViewModel
|
||||
) {
|
||||
val showCounts = remember(likeCount) { showCount(likeCount) }
|
||||
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
val reactions by model.todaysReactionCount.collectAsState("")
|
||||
|
||||
Text(
|
||||
text = showCounts,
|
||||
text = reactions,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
@@ -404,17 +417,9 @@ fun UserLikeReaction(
|
||||
|
||||
@Composable
|
||||
fun UserZapReaction(
|
||||
amount: String
|
||||
model: UserReactionsViewModel
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Size24Modifier,
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
val amount by model.todaysZapAmount.collectAsState("")
|
||||
Text(
|
||||
amount,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.widget.Toast
|
||||
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.outlined.Done
|
||||
import androidx.compose.runtime.*
|
||||
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.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -28,8 +30,6 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ZapOptionstViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
@@ -58,17 +58,20 @@ class ZapOptionstViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, baseNote: Note) {
|
||||
fun ZapCustomDialog(
|
||||
onClose: () -> Unit,
|
||||
onError: (text: String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
baseNote: Note
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val postViewModel: ZapOptionstViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
postViewModel.load(accountViewModel.account)
|
||||
}
|
||||
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
val zapTypes = listOf(
|
||||
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
|
||||
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
|
||||
@@ -94,7 +97,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
@@ -108,18 +111,8 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
|
||||
null,
|
||||
postViewModel.customMessage.text,
|
||||
context,
|
||||
onError = {
|
||||
zappingProgress = 0f
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
zapType = selectedZapType
|
||||
)
|
||||
onClose()
|
||||
@@ -225,3 +218,51 @@ fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
|
||||
Text(text = "⚡Zap ", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorMessageDialog(
|
||||
title: String,
|
||||
textContent: String,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
onClickStartMessage: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
text = {
|
||||
Text(textContent)
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = onClickStartMessage) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_dm),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.error_dialog_talk_to_user))
|
||||
}
|
||||
Button(onClick = onDismiss, colors = buttonColors) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Done,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.error_dialog_button_ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -22,6 +23,7 @@ import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
@@ -89,5 +91,9 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
|
||||
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onClose)
|
||||
CloseButton(onPress = onClose)
|
||||
}
|
||||
|
||||
Column(
|
||||
|
||||
@@ -13,11 +13,9 @@ import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -89,8 +87,7 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
|
||||
} else {
|
||||
_accountContent.update { AccountState.LoggedInViewOnly(account) }
|
||||
}
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start(account, context)
|
||||
}
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
|
||||
@@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -102,11 +101,9 @@ private fun WatchScrollToTop(
|
||||
val scrollToTop by viewModel.scrollToTop.collectAsState()
|
||||
|
||||
LaunchedEffect(scrollToTop) {
|
||||
launch {
|
||||
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
|
||||
listState.scrollToItem(index = 0)
|
||||
viewModel.sentToTop()
|
||||
}
|
||||
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
|
||||
listState.scrollToItem(index = 0)
|
||||
viewModel.sentToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,12 @@ import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Stable
|
||||
@@ -76,8 +74,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
private var lastNotes: Set<Note>? = null
|
||||
|
||||
fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
@@ -217,8 +214,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
|
||||
private fun updateFeed(notes: ImmutableList<Card>) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
val currentState = _feedContent.value
|
||||
|
||||
if (notes.isEmpty()) {
|
||||
@@ -273,7 +269,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
private val bundler = BundledUpdate(1000, Dispatchers.IO)
|
||||
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun invalidateData(ignoreIfDoing: Boolean = false) {
|
||||
bundler.invalidate(ignoreIfDoing) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
@@ -285,7 +280,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun invalidateDataAndSendToTop() {
|
||||
clear()
|
||||
bundler.invalidate(false) {
|
||||
@@ -299,7 +293,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun checkKeysInvalidateDataAndSendToTop() {
|
||||
if (lastFeedKey != localFilter.feedKey()) {
|
||||
clear()
|
||||
@@ -315,7 +308,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun invalidateInsertData(newItems: Set<Note>) {
|
||||
bundlerInsert.invalidateList(newItems) {
|
||||
val newObjects = it.flatten().toSet()
|
||||
@@ -353,6 +345,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
clear()
|
||||
bundlerInsert.cancel()
|
||||
bundler.cancel()
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -96,10 +95,10 @@ class NostrDiscoverChatFeedViewModel(val account: Account) : FeedViewModel(Disco
|
||||
}
|
||||
}
|
||||
|
||||
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
|
||||
class Factory(val noteId: String) : ViewModelProvider.Factory {
|
||||
class NostrThreadFeedViewModel(account: Account, noteId: String) : FeedViewModel(ThreadFeedFilter(account, noteId)) {
|
||||
class Factory(val account: Account, val noteId: String) : ViewModelProvider.Factory {
|
||||
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
|
||||
return NostrThreadFeedViewModel(noteId) as NostrThreadFeedViewModel
|
||||
return NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,8 +227,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
@@ -238,7 +236,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
|
||||
checkNotInMainThread()
|
||||
|
||||
lastFeedKey = localFilter.feedKey()
|
||||
val notes = localFilter.loadTop().toImmutableList()
|
||||
val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
|
||||
|
||||
val oldNotesState = _feedContent.value
|
||||
if (oldNotesState is FeedState.Loaded) {
|
||||
@@ -251,8 +249,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
|
||||
}
|
||||
|
||||
private fun updateFeed(notes: ImmutableList<Note>) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
val currentState = _feedContent.value
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { FeedState.Empty }
|
||||
@@ -304,11 +301,13 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
|
||||
|
||||
fun checkKeysInvalidateDataAndSendToTop() {
|
||||
if (lastFeedKey != localFilter.feedKey()) {
|
||||
bundler.invalidate(false) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
sendToTop()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
bundler.invalidate(false) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
sendToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,6 +339,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
bundlerInsert.cancel()
|
||||
bundler.cancel()
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user