mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
434 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c29bf3f25 | ||
|
|
9642e45775 | ||
|
|
58788d2cd5 | ||
|
|
d7c18341cd | ||
|
|
ee16f6c143 | ||
|
|
d78b55d134 | ||
|
|
ff5e7359e2 | ||
|
|
4fd0f38402 | ||
|
|
5b516f4101 | ||
|
|
1fc390c215 | ||
|
|
2bb09f583b | ||
|
|
b4eba2da02 | ||
|
|
786a173ba5 | ||
|
|
5ef3c8f00e | ||
|
|
d832351bcd | ||
|
|
9ae611831f | ||
|
|
6db287a008 | ||
|
|
99641e1a12 | ||
|
|
abb2f0557d | ||
|
|
76a66e7bd3 | ||
|
|
6cd50a115b | ||
|
|
0ed34cec09 | ||
|
|
c5e3657bfe | ||
|
|
da47965d76 | ||
|
|
a66417ec82 | ||
|
|
62c3c93d2e | ||
|
|
fbecc8b6d1 | ||
|
|
e95afa89ff | ||
|
|
d6f2560c1c | ||
|
|
d320b951ae | ||
|
|
af28e6d466 | ||
|
|
86e6d56dcd | ||
|
|
c66fa1073b | ||
|
|
6d9bffe9e7 | ||
|
|
04158cec6e | ||
|
|
76376a7c40 | ||
|
|
67ea0dcf7f | ||
|
|
cdc8850894 | ||
|
|
ce0c7c842d | ||
|
|
f51f929079 | ||
|
|
d87d2e0ef8 | ||
|
|
a3166f4ff3 | ||
|
|
9340c440f5 | ||
|
|
a7343d5b54 | ||
|
|
0551b82bd9 | ||
|
|
18fca8adac | ||
|
|
5d788ed669 | ||
|
|
54d52e0b77 | ||
|
|
28c92675b2 | ||
|
|
baa9ee0562 | ||
|
|
7326d6be93 | ||
|
|
e187e5392f | ||
|
|
7c8f6eb239 | ||
|
|
6294c65ad6 | ||
|
|
51d9657371 | ||
|
|
32783794ee | ||
|
|
e3858dc830 | ||
|
|
38a9030963 | ||
|
|
29c0c4cf5f | ||
|
|
dcbde81a23 | ||
|
|
56e6f06fe7 | ||
|
|
342b64f0eb | ||
|
|
a64a640165 | ||
|
|
6433830c32 | ||
|
|
3a58819b52 | ||
|
|
5c7be44369 | ||
|
|
6e66d008dc | ||
|
|
c61c4ce16c | ||
|
|
246ce55253 | ||
|
|
a704c81d24 | ||
|
|
9963245ec9 | ||
|
|
64016aa940 | ||
|
|
c6462f102a | ||
|
|
b05083863d | ||
|
|
86fffe6539 | ||
|
|
f94a4cfa65 | ||
|
|
3825642ae7 | ||
|
|
f90f81b2a5 | ||
|
|
bc042e52b0 | ||
|
|
d80b22fa29 | ||
|
|
b63ed4fdbd | ||
|
|
397acce85b | ||
|
|
5305929df3 | ||
|
|
6d4ba6d507 | ||
|
|
a028c2c3fc | ||
|
|
86e5dfa799 | ||
|
|
3aa316d262 | ||
|
|
3f4efdb6d9 | ||
|
|
a178e5f589 | ||
|
|
cc5a6cdaf2 | ||
|
|
d9596335de | ||
|
|
44c600dfa8 | ||
|
|
a92fe0bca2 | ||
|
|
dea9d698df | ||
|
|
94ae7b3da1 | ||
|
|
da1c94f77b | ||
|
|
64ff33e745 | ||
|
|
8810cfde37 | ||
|
|
c7b7ad78b7 | ||
|
|
ff9f25ccdb | ||
|
|
498595b892 | ||
|
|
56878900b0 | ||
|
|
d8d6736d55 | ||
|
|
ddc68e61d5 | ||
|
|
d4e8fc5737 | ||
|
|
3a7dfe7d03 | ||
|
|
d23a859f34 | ||
|
|
824cb4e5f1 | ||
|
|
35aeeb571c | ||
|
|
70d68c89c8 | ||
|
|
35a6331da2 | ||
|
|
6229a0ad63 | ||
|
|
620285596d | ||
|
|
fd2637ff42 | ||
|
|
afe8a06486 | ||
|
|
b682e90f81 | ||
|
|
ec700ca61f | ||
|
|
9b26ffd92a | ||
|
|
a8ca9ab727 | ||
|
|
e5832f6a45 | ||
|
|
8b3035f635 | ||
|
|
9f17cc50f8 | ||
|
|
d0633d07fa | ||
|
|
97b88c7ceb | ||
|
|
4961a22753 | ||
|
|
45b568f6a7 | ||
|
|
446859e8bd | ||
|
|
e1b24c22b9 | ||
|
|
e0b1c4dc3f | ||
|
|
5722507b3d | ||
|
|
74df309527 | ||
|
|
740c6a8c33 | ||
|
|
929a263665 | ||
|
|
6f44c02b5c | ||
|
|
bce01dc264 | ||
|
|
7919637e12 | ||
|
|
28da06ef01 | ||
|
|
428ed464af | ||
|
|
5921b712b0 | ||
|
|
5c9a37f01c | ||
|
|
514a69deec | ||
|
|
356cbc24d4 | ||
|
|
f4a2f247f7 | ||
|
|
9ee28e28b8 | ||
|
|
2457429470 | ||
|
|
b998381bb6 | ||
|
|
74725235b7 | ||
|
|
ef4d975c7c | ||
|
|
3fdd3f9b69 | ||
|
|
53aa9b0017 | ||
|
|
a52d87a555 | ||
|
|
39f062fe32 | ||
|
|
b2c13089ce | ||
|
|
4ddf307b9f | ||
|
|
3db36d8c67 | ||
|
|
bba7d3455c | ||
|
|
f086c0fe10 | ||
|
|
abc803953e | ||
|
|
8841aa9a10 | ||
|
|
7e417f09f7 | ||
|
|
0fde6ffd30 | ||
|
|
6f291139d8 | ||
|
|
1e3104d489 | ||
|
|
2e110422c8 | ||
|
|
7b7aa1932f | ||
|
|
773c96997c | ||
|
|
8f8199f489 | ||
|
|
a63d50c24f | ||
|
|
6af2b48d23 | ||
|
|
37197e1f8a | ||
|
|
8ede4eb3d6 | ||
|
|
c7fcc47b11 | ||
|
|
f12cf93e9f | ||
|
|
00e354394f | ||
|
|
bbb548fe21 | ||
|
|
c19e1fc56d | ||
|
|
065cf83b68 | ||
|
|
ca0da1dcc8 | ||
|
|
58a5cbc624 | ||
|
|
554fef9eae | ||
|
|
7dd76f866a | ||
|
|
84bcc33e87 | ||
|
|
6309aa1711 | ||
|
|
a8a3bc8a6b | ||
|
|
d2b4a859bf | ||
|
|
92d11d84a7 | ||
|
|
1cba6e6cdb | ||
|
|
20115c7080 | ||
|
|
4b3d986c5b | ||
|
|
21112d3d46 | ||
|
|
ec3f307e0b | ||
|
|
e17cfb4fdb | ||
|
|
010305fbeb | ||
|
|
5af473870e | ||
|
|
80f805617a | ||
|
|
3e1184562f | ||
|
|
39edbd22cc | ||
|
|
c90e615d5b | ||
|
|
5e6e7a3b5f | ||
|
|
3e23d6e526 | ||
|
|
08054e226e | ||
|
|
cfe668ab0d | ||
|
|
8f19276fac | ||
|
|
3a3c0d74d2 | ||
|
|
3ad21e72a9 | ||
|
|
790b9359bd | ||
|
|
e52e2bfdc9 | ||
|
|
a34c763656 | ||
|
|
adf5bbe80f | ||
|
|
c4c542c739 | ||
|
|
5f0236c015 | ||
|
|
023c0592e8 | ||
|
|
c6d2f05f15 | ||
|
|
08e9f6625a | ||
|
|
fcf5e972d6 | ||
|
|
20bf4dbc29 | ||
|
|
990c5df2b9 | ||
|
|
71af88725a | ||
|
|
fb5f5c3eba | ||
|
|
ead588314e | ||
|
|
fb447e441a | ||
|
|
3d61343905 | ||
|
|
7e4d9fb708 | ||
|
|
0217fd3eb3 | ||
|
|
96fa6f39b0 | ||
|
|
b6bb214532 | ||
|
|
ee160b76a8 | ||
|
|
2fd9d6610a | ||
|
|
272f99be17 | ||
|
|
b8246d7cd4 | ||
|
|
981d93f2fc | ||
|
|
1166fe92e9 | ||
|
|
b700d4e9bf | ||
|
|
0b41d09fda | ||
|
|
61c145a68f | ||
|
|
4103342f99 | ||
|
|
e48ed607ff | ||
|
|
d5a98db67c | ||
|
|
72412b9d03 | ||
|
|
b5b90c80e9 | ||
|
|
c1077f689d | ||
|
|
8b26a84564 | ||
|
|
2ca5186aef | ||
|
|
064e211154 | ||
|
|
2e8ab35f05 | ||
|
|
df429b976f | ||
|
|
d40995c338 | ||
|
|
bba7c257da | ||
|
|
a03e02bfeb | ||
|
|
a4a753e7b6 | ||
|
|
53acbd8606 | ||
|
|
f89c1ff391 | ||
|
|
471ee21749 | ||
|
|
24f7e043af | ||
|
|
34e82dab8b | ||
|
|
9fa1b8c541 | ||
|
|
1e61162f73 | ||
|
|
941266365e | ||
|
|
b1f1466e6b | ||
|
|
55d53dbc4f | ||
|
|
aeaddf722b | ||
|
|
b733fbce35 | ||
|
|
93e9e23730 | ||
|
|
388ccdbe75 | ||
|
|
255f464046 | ||
|
|
8be74649ce | ||
|
|
b5709f9527 | ||
|
|
7d9efcd1d9 | ||
|
|
d44f5dad18 | ||
|
|
06f37ab81d | ||
|
|
cb4a73bb9c | ||
|
|
efde14a348 | ||
|
|
0f86e3d8fb | ||
|
|
1407850e81 | ||
|
|
d9a87f6e7c | ||
|
|
5e0a49f225 | ||
|
|
3ebaf2bf1e | ||
|
|
9ef0ceed9d | ||
|
|
caa3760fd3 | ||
|
|
0b2a9e67c0 | ||
|
|
931081c5e7 | ||
|
|
f7237b5666 | ||
|
|
d851637ea2 | ||
|
|
2d39b4edfe | ||
|
|
5001c5bef8 | ||
|
|
6ac1db9fc8 | ||
|
|
52f33d79e0 | ||
|
|
1a9d91cdc0 | ||
|
|
2f444e6ec5 | ||
|
|
4665c488d5 | ||
|
|
42ab826d02 | ||
|
|
80a3f7424b | ||
|
|
1bd3e347b3 | ||
|
|
a332126e71 | ||
|
|
d97abe6af9 | ||
|
|
62d341bd8a | ||
|
|
a95d2e903e | ||
|
|
2b5e62aaf7 | ||
|
|
1c3e82913f | ||
|
|
ceaa20fabf | ||
|
|
1bbabb747e | ||
|
|
914c0a2926 | ||
|
|
4b249156f9 | ||
|
|
7b8c7a1a78 | ||
|
|
66ed26dcea | ||
|
|
356740479c | ||
|
|
c88dded01b | ||
|
|
7c974bc8ef | ||
|
|
efbc3e3e78 | ||
|
|
fac803e208 | ||
|
|
484a3d6b32 | ||
|
|
4e3b6d0299 | ||
|
|
f3161ada8d | ||
|
|
5c9b84476b | ||
|
|
24ea0cabda | ||
|
|
8b617fbd60 | ||
|
|
34149e6ce2 | ||
|
|
315a662c10 | ||
|
|
c373843e0f | ||
|
|
448ea796ef | ||
|
|
71b111ce8b | ||
|
|
ae686671ba | ||
|
|
6c45bb1ca1 | ||
|
|
a05ad6e686 | ||
|
|
485f7becd4 | ||
|
|
83f1e523ea | ||
|
|
a8a3c94115 | ||
|
|
4d21857adf | ||
|
|
0e7f41efe8 | ||
|
|
8d9bf60dba | ||
|
|
7c67924914 | ||
|
|
7626839037 | ||
|
|
23825ceed5 | ||
|
|
8d8016e01b | ||
|
|
6668e5a178 | ||
|
|
7d6bef9576 | ||
|
|
1aea7fbd03 | ||
|
|
ef96d296f8 | ||
|
|
d4da58e14a | ||
|
|
f646866192 | ||
|
|
e0849b12e7 | ||
|
|
1271343582 | ||
|
|
d61d14ea75 | ||
|
|
668d604aa9 | ||
|
|
6df2c3a0cf | ||
|
|
b0c33808e6 | ||
|
|
7bd3e0beab | ||
|
|
3a70cfb9e2 | ||
|
|
17c758f8f8 | ||
|
|
7cb03b939f | ||
|
|
481ef91cb1 | ||
|
|
1183aa8d11 | ||
|
|
4023261400 | ||
|
|
f1433504e9 | ||
|
|
989cbb35d3 | ||
|
|
9262b90096 | ||
|
|
ab3b7685cf | ||
|
|
40865ac97d | ||
|
|
ded67964b0 | ||
|
|
e991707ddf | ||
|
|
1b429475aa | ||
|
|
9613afb7ea | ||
|
|
26a12f2dbe | ||
|
|
23da02f932 | ||
|
|
59ff9007a7 | ||
|
|
0f817dbf00 | ||
|
|
7ab6322886 | ||
|
|
223c0c1263 | ||
|
|
d3a639a2ee | ||
|
|
2fb1cbe11f | ||
|
|
29c5b45764 | ||
|
|
ec83ad9d20 | ||
|
|
239f973e89 | ||
|
|
6c81fbbcf9 | ||
|
|
75e45f37b2 | ||
|
|
0bff3462b1 | ||
|
|
990db68a4d | ||
|
|
4343c744db | ||
|
|
5cb6f97178 | ||
|
|
a8efc9d348 | ||
|
|
e0b667fd2d | ||
|
|
a8ad2437fe | ||
|
|
83018e621e | ||
|
|
68b15ba483 | ||
|
|
7513036d47 | ||
|
|
c82c594926 | ||
|
|
bd18d4b4b2 | ||
|
|
2f5b99a1c7 | ||
|
|
1be6e0ec0f | ||
|
|
f43c0e5cf5 | ||
|
|
d6d8719c8d | ||
|
|
7478899388 | ||
|
|
6b193894bd | ||
|
|
69f5371ab8 | ||
|
|
526e3db3c1 | ||
|
|
3c9e8854eb | ||
|
|
7ee0aa47a9 | ||
|
|
5fbf0462fa | ||
|
|
eda5bc50e6 | ||
|
|
d6d26d466b | ||
|
|
6acd3ca091 | ||
|
|
c64d179f7f | ||
|
|
32bc8fe667 | ||
|
|
0a8c792d53 | ||
|
|
c32dc61ea1 | ||
|
|
3af2cb52cc | ||
|
|
ddb1d7ff82 | ||
|
|
fd0523085d | ||
|
|
1f9fbb0c86 | ||
|
|
5b95a1c177 | ||
|
|
2e144ebdf8 | ||
|
|
ac5f550ff3 | ||
|
|
9af123b709 | ||
|
|
75efd2b372 | ||
|
|
2cface9ac9 | ||
|
|
58491a97ac | ||
|
|
7a86d78c50 | ||
|
|
97a00ff83c | ||
|
|
4c6842faf4 | ||
|
|
fad8b9df0f | ||
|
|
cc52681635 | ||
|
|
939c8e95b0 | ||
|
|
312501e527 | ||
|
|
89638ff261 | ||
|
|
27d2e2309a | ||
|
|
7eaa553ebe | ||
|
|
8adddd0bdb | ||
|
|
d8f084f886 | ||
|
|
5c5a23dfbb | ||
|
|
282d4614c0 | ||
|
|
8ed78ea38f | ||
|
|
2e7b7c5c00 | ||
|
|
68cad97819 | ||
|
|
02f7b54661 |
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -24,9 +24,10 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add a video and/or screenshots to help explain your problem.
|
||||
|
||||
**Device (please complete the following information):**
|
||||
- Phone Brand/Model [e.g. Pixel 7 Pro]:
|
||||
- Android Version [e.g. 33]:
|
||||
- App Version [e.g. v0.20.3]:
|
||||
- Phone Brand/Model [e.g. Pixel 7 Pro]:
|
||||
- Android Version [e.g. 33]:
|
||||
- App Version [e.g. v0.20.3]:
|
||||
- Amber Version (if using it to sign):
|
||||
|
||||
**Bounty (in Bitcoin sats) offered for a solution**
|
||||
Incentivize developers to work on your issue. Describe clear milestones to claim payment.
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -43,13 +43,13 @@ jobs:
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Debug APK
|
||||
path: amethyst/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build Reports
|
||||
path: amethyst/build/reports
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@
|
||||
/.idea/ktfmt.xml
|
||||
/.idea/studiobot.xml
|
||||
/.idea/other.xml
|
||||
/.idea/runConfigurations.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
32
.idea/inspectionProfiles/Project_Default.xml
generated
32
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,6 +1,34 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
@@ -9,6 +37,10 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
|
||||
@@ -16,9 +16,9 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk libs.versions.android.minSdk.get().toInteger()
|
||||
targetSdk libs.versions.android.targetSdk.get().toInteger()
|
||||
versionCode 395
|
||||
versionName "0.89.9"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"1130badb252bdf62aaf93cb65bfa2bb09e7600c7d6764894f2954555b3b73018\""
|
||||
versionCode 408
|
||||
versionName "0.92.3"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"98f2acaf9b3eb70d87607942e576f9a086ac28adf37869da9a29f4815cecac86\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -164,6 +164,7 @@ kotlin {
|
||||
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
metricsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -252,6 +253,7 @@ dependencies {
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.window.core.android
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation libs.google.mlkit.language.id
|
||||
@@ -286,8 +288,12 @@ dependencies {
|
||||
// Cbor for cashuB format
|
||||
implementation libs.kotlinx.serialization.cbor
|
||||
|
||||
implementation libs.tor.android
|
||||
implementation libs.jtorctl
|
||||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.mockk
|
||||
testImplementation libs.kotlinx.coroutines.test
|
||||
|
||||
androidTestImplementation platform(libs.androidx.compose.bom)
|
||||
androidTestImplementation libs.androidx.junit
|
||||
@@ -297,5 +303,11 @@ dependencies {
|
||||
debugImplementation platform(libs.androidx.compose.bom)
|
||||
debugImplementation libs.androidx.ui.tooling
|
||||
debugImplementation libs.androidx.ui.test.manifest
|
||||
|
||||
implementation libs.androidx.camera.core
|
||||
implementation libs.androidx.camera.camera2
|
||||
implementation libs.androidx.camera.lifecycle
|
||||
implementation libs.androidx.camera.view
|
||||
implementation libs.androidx.camera.extensions
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import android.graphics.Color
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||
@@ -33,6 +34,9 @@ import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert
|
||||
import org.junit.Ignore
|
||||
@@ -48,6 +52,7 @@ class ImageUploadTesting {
|
||||
Nip96Retriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
false,
|
||||
)
|
||||
|
||||
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
|
||||
@@ -61,7 +66,11 @@ class ImageUploadTesting {
|
||||
val bytes = baos.toByteArray()
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
val account = Account(KeyPair())
|
||||
val account =
|
||||
Account(
|
||||
AccountSettings(KeyPair()),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
)
|
||||
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
@@ -72,6 +81,7 @@ class ImageUploadTesting {
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverInfo,
|
||||
forceProxy = { false },
|
||||
onProgress = {},
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
)
|
||||
@@ -98,7 +108,7 @@ class ImageUploadTesting {
|
||||
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
|
||||
|
||||
val imageData: ByteArray =
|
||||
ImageDownloader().waitAndGetImage(url)
|
||||
ImageDownloader().waitAndGetImage(url, false)
|
||||
?: run {
|
||||
fail("${server.name}: Should not be null")
|
||||
return
|
||||
@@ -180,6 +190,12 @@ class ImageUploadTesting {
|
||||
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
|
||||
}
|
||||
|
||||
@Test(expected = RuntimeException::class)
|
||||
fun testSprovoostNl() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrOnch() =
|
||||
|
||||
@@ -46,7 +46,7 @@ class OkHttpOtsTest {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
@@ -30,7 +31,9 @@ import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import junit.framework.TestCase
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.Test
|
||||
@@ -133,7 +136,7 @@ class ThreadAssemblerTest {
|
||||
null,
|
||||
)
|
||||
|
||||
val account = Account(KeyPair())
|
||||
val account = Account(AccountSettings(KeyPair()), scope = CoroutineScope(Dispatchers.IO + SupervisorJob()))
|
||||
withContext(Dispatchers.Main) {
|
||||
val user = account.userProfile().live()
|
||||
}
|
||||
|
||||
@@ -38,13 +38,11 @@ interface PushDistributorActions {
|
||||
}
|
||||
|
||||
object PushDistributorHandler : PushDistributorActions {
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val unifiedPush: UnifiedPush = UnifiedPush
|
||||
|
||||
private var endpointInternal = ""
|
||||
val endpoint = endpointInternal
|
||||
|
||||
fun getSavedEndpoint() = endpoint
|
||||
fun getSavedEndpoint() = endpointInternal
|
||||
|
||||
fun setEndpoint(newEndpoint: String) {
|
||||
endpointInternal = newEndpoint
|
||||
@@ -55,11 +53,13 @@ object PushDistributorHandler : PushDistributorActions {
|
||||
endpointInternal = ""
|
||||
}
|
||||
|
||||
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext) ?: ""
|
||||
fun appContext(): Context = Amethyst.instance.applicationContext
|
||||
|
||||
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext()) ?: ""
|
||||
|
||||
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
|
||||
|
||||
override fun getInstalledDistributors(): List<String> = unifiedPush.getDistributors(appContext)
|
||||
override fun getInstalledDistributors(): List<String> = unifiedPush.getDistributors(appContext())
|
||||
|
||||
fun formattedDistributorNames(): List<String> {
|
||||
val distributorsArray = getInstalledDistributors().toTypedArray()
|
||||
@@ -69,16 +69,16 @@ object PushDistributorHandler : PushDistributorActions {
|
||||
try {
|
||||
val ai =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext.packageManager.getApplicationInfo(
|
||||
appContext().packageManager.getApplicationInfo(
|
||||
it,
|
||||
PackageManager.ApplicationInfoFlags.of(
|
||||
PackageManager.GET_META_DATA.toLong(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
appContext.packageManager.getApplicationInfo(it, 0)
|
||||
appContext().packageManager.getApplicationInfo(it, 0)
|
||||
}
|
||||
appContext.packageManager.getApplicationLabel(ai)
|
||||
appContext().packageManager.getApplicationLabel(ai)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
it
|
||||
}
|
||||
@@ -88,12 +88,12 @@ object PushDistributorHandler : PushDistributorActions {
|
||||
}
|
||||
|
||||
override fun saveDistributor(distributor: String) {
|
||||
unifiedPush.saveDistributor(appContext, distributor)
|
||||
unifiedPush.registerApp(appContext)
|
||||
unifiedPush.saveDistributor(appContext(), distributor)
|
||||
unifiedPush.registerApp(appContext())
|
||||
}
|
||||
|
||||
override fun removeSavedDistributor() {
|
||||
unifiedPush.safeRemoveDistributor(appContext)
|
||||
unifiedPush.safeRemoveDistributor(appContext())
|
||||
}
|
||||
|
||||
fun forceRemoveDistributor(context: Context) {
|
||||
|
||||
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -33,10 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
|
||||
@@ -46,7 +42,7 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
}
|
||||
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val scope = Amethyst.instance.applicationIOScope
|
||||
private val eventCache = LruCache<String, String>(100)
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
@@ -55,11 +51,13 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
message: ByteArray,
|
||||
instance: String,
|
||||
) {
|
||||
val messageStr = String(message)
|
||||
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
|
||||
val messageStr = message.decodeToString()
|
||||
Log.d(TAG, "New message $messageStr for Instance: $instance")
|
||||
scope.launch {
|
||||
try {
|
||||
parseMessage(messageStr)?.let { receiveIfNew(it) }
|
||||
parseMessage(messageStr)?.let {
|
||||
receiveIfNew(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d(TAG, "Message could not be parsed: ${e.message}")
|
||||
@@ -86,32 +84,25 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
endpoint: String,
|
||||
instance: String,
|
||||
) {
|
||||
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
|
||||
val sanitizedEndpoint = endpoint.dropLast(5)
|
||||
pushHandler.setEndpoint(sanitizedEndpoint)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
|
||||
notificationManager().getOrCreateZapChannel(appContext)
|
||||
notificationManager().getOrCreateDMChannel(appContext)
|
||||
val sanitizedEndpoint = if (endpoint.endsWith("?up=1")) endpoint.dropLast(5) else endpoint
|
||||
if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) {
|
||||
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
|
||||
pushHandler.setEndpoint(sanitizedEndpoint)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
|
||||
notificationManager().getOrCreateZapChannel(appContext)
|
||||
notificationManager().getOrCreateDMChannel(appContext)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
val intentData = intent.dataString
|
||||
val intentAction = intent.action.toString()
|
||||
Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction")
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(
|
||||
context: Context,
|
||||
instance: String,
|
||||
) {
|
||||
Log.d(TAG, "Registration failed for Instance: $instance")
|
||||
scope.cancel()
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
}
|
||||
|
||||
@@ -119,7 +110,7 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
context: Context,
|
||||
instance: String,
|
||||
) {
|
||||
val removedEndpoint = pushHandler.endpoint
|
||||
val removedEndpoint = pushHandler.getSavedEndpoint()
|
||||
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
|
||||
Log.d(TAG, "App is unregistered. ")
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
|
||||
@@ -29,11 +29,14 @@ object PushNotificationUtils {
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
suspend fun init(accounts: List<AccountInfo>) {
|
||||
if (hasInit || pushHandler.savedDistributorExists()) {
|
||||
if (hasInit) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (pushHandler.savedDistributorExists()) {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor()
|
||||
PushDistributorHandler.saveDistributor(currentDistributor)
|
||||
|
||||
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -55,10 +55,10 @@ import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNotificationPermission
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -67,7 +67,7 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
@Composable
|
||||
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val notificationPermissionState =
|
||||
CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
|
||||
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
|
||||
|
||||
if (notificationPermissionState.status.isGranted) {
|
||||
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
|
||||
@@ -150,9 +150,6 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor()
|
||||
PushDistributorHandler.saveDistributor(currentDistributor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
@@ -38,7 +39,7 @@ fun TranslatableRichTextViewer(
|
||||
id: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<!-- To connect with relays -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- To take pictures -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- To Upload media (newer devices) -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
@@ -121,6 +124,16 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package androidx.compose.material3.adaptive
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.toComposeRect
|
||||
import androidx.window.layout.FoldingFeature
|
||||
|
||||
/**
|
||||
* Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework
|
||||
* folding info into the Material-opinionated posture info.
|
||||
*/
|
||||
fun calculatePosture(foldingFeatures: List<FoldingFeature>): Posture {
|
||||
var isTableTop = false
|
||||
val hingeList = mutableListOf<HingeInfo>()
|
||||
@Suppress("ListIterator")
|
||||
foldingFeatures.forEach {
|
||||
if (it.orientation == FoldingFeature.Orientation.HORIZONTAL &&
|
||||
it.state == FoldingFeature.State.HALF_OPENED
|
||||
) {
|
||||
isTableTop = true
|
||||
}
|
||||
hingeList.add(
|
||||
HingeInfo(
|
||||
bounds = it.bounds.toComposeRect(),
|
||||
isFlat = it.state == FoldingFeature.State.FLAT,
|
||||
isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL,
|
||||
isSeparating = it.isSeparating,
|
||||
isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Posture(isTableTop, hingeList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Posture info that can help make layout adaptation decisions. For example when
|
||||
* [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any
|
||||
* content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this
|
||||
* class in applications, unless you have a strong need of customization that cannot be fulfilled by
|
||||
* the default implementation.
|
||||
*
|
||||
* Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout
|
||||
* coordinate.
|
||||
*
|
||||
* @constructor create an instance of [Posture]
|
||||
* @property isTabletop `true` if the current window is considered as in the table top mode, i.e.
|
||||
* there is one half-opened horizontal hinge in the middle of the current window. When
|
||||
* this is `true` it usually means it's hard for users to interact with the window area
|
||||
* around the hinge and developers may consider separating the layout along the hinge and
|
||||
* show software keyboard or other controls in the bottom half of the window.
|
||||
* @property hingeList a list of all hinges that are relevant to the posture.
|
||||
*/
|
||||
@Immutable
|
||||
class Posture(
|
||||
val isTabletop: Boolean = false,
|
||||
val hingeList: List<HingeInfo> = emptyList(),
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Posture) return false
|
||||
if (isTabletop != other.isTabletop) return false
|
||||
if (hingeList != other.hingeList) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = isTabletop.hashCode()
|
||||
result = 31 * result + hingeList.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
@Suppress("ListIterator")
|
||||
return "Posture(isTabletop=$isTabletop, " +
|
||||
"hinges=[${hingeList.joinToString(", ")}])"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of vertical hinge bounds that are separating.
|
||||
*/
|
||||
val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating }
|
||||
|
||||
/**
|
||||
* Returns the list of vertical hinge bounds that are occluding.
|
||||
*/
|
||||
val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding }
|
||||
|
||||
/**
|
||||
* Returns the list of all vertical hinge bounds.
|
||||
*/
|
||||
val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical }
|
||||
|
||||
/**
|
||||
* Returns the list of horizontal hinge bounds that are separating.
|
||||
*/
|
||||
val Posture.separatingHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical && isSeparating }
|
||||
|
||||
/**
|
||||
* Returns the list of horizontal hinge bounds that are occluding.
|
||||
*/
|
||||
val Posture.occludingHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical && isOccluding }
|
||||
|
||||
/**
|
||||
* Returns the list of all horizontal hinge bounds.
|
||||
*/
|
||||
val Posture.allHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical }
|
||||
|
||||
/**
|
||||
* A class that contains the info of a hinge relevant to a [Posture].
|
||||
*
|
||||
* @param bounds the bounds of the hinge in the relevant viewport.
|
||||
* @param isFlat `true` if the hinge is fully open and the relevant window space presented to the
|
||||
* user is flat.
|
||||
* @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into
|
||||
* left and right; `false` if the hinge is horizontal, i.e., it separates the viewport
|
||||
* into top and bottom.
|
||||
* @param isSeparating `true` if the hinge creates two logical display areas.
|
||||
* @param isOccluding `true` if the hinge conceals part of the display.
|
||||
*/
|
||||
@Immutable
|
||||
class HingeInfo(
|
||||
val bounds: Rect,
|
||||
val isFlat: Boolean,
|
||||
val isVertical: Boolean,
|
||||
val isSeparating: Boolean,
|
||||
val isOccluding: Boolean,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is HingeInfo) return false
|
||||
if (bounds != other.bounds) return false
|
||||
if (isFlat != other.isFlat) return false
|
||||
if (isVertical != other.isVertical) return false
|
||||
if (isSeparating != other.isSeparating) return false
|
||||
if (isOccluding != other.isOccluding) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = bounds.hashCode()
|
||||
result = 31 * result + isFlat.hashCode()
|
||||
result = 31 * result + isVertical.hashCode()
|
||||
result = 31 * result + isSeparating.hashCode()
|
||||
result = 31 * result + isOccluding.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String =
|
||||
"HingeInfo(bounds=$bounds, " +
|
||||
"isFlat=$isFlat, " +
|
||||
"isVertical=$isVertical, " +
|
||||
"isSeparating=$isSeparating, " +
|
||||
"isOccluding=$isOccluding)"
|
||||
}
|
||||
|
||||
private inline fun List<HingeInfo>.getBounds(predicate: HingeInfo.() -> Boolean): List<Rect> =
|
||||
@Suppress("ListIterator")
|
||||
mapNotNull { if (it.predicate()) it.bounds else null }
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package androidx.compose.material3.adaptive
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import androidx.window.layout.WindowInfoTracker
|
||||
import androidx.window.layout.WindowMetricsCalculator
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Composable
|
||||
fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo {
|
||||
val windowSize =
|
||||
with(LocalDensity.current) {
|
||||
currentWindowSize().toSize().toDpSize()
|
||||
}
|
||||
return WindowAdaptiveInfo(
|
||||
WindowSizeClass.compute(windowSize.width.value, windowSize.height.value),
|
||||
calculatePosture(collectFoldingFeaturesAsState().value),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns and automatically update the current window size from [WindowMetricsCalculator].
|
||||
*
|
||||
* @return an [IntSize] that represents the current window size.
|
||||
*/
|
||||
@Composable
|
||||
fun currentWindowSize(): IntSize {
|
||||
// Observe view configuration changes and recalculate the size class on each change. We can't
|
||||
// use Activity#onConfigurationChanged as this will sometimes fail to be called on different
|
||||
// API levels, hence why this function needs to be @Composable so we can observe the
|
||||
// ComposeView's configuration changes.
|
||||
LocalConfiguration.current
|
||||
val windowBounds =
|
||||
WindowMetricsCalculator
|
||||
.getOrCreate()
|
||||
.computeCurrentWindowMetrics(LocalContext.current)
|
||||
.bounds
|
||||
return IntSize(windowBounds.width(), windowBounds.height())
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the current window folding features from [WindowInfoTracker] in to a [State].
|
||||
*
|
||||
* @return a [State] of a [FoldingFeature] list.
|
||||
*/
|
||||
@Composable
|
||||
fun collectFoldingFeaturesAsState(): State<List<FoldingFeature>> {
|
||||
val context = LocalContext.current
|
||||
return remember(context) {
|
||||
if (context is Activity) {
|
||||
// TODO(b/284347941) remove the instance check after the test bug is fixed.
|
||||
WindowInfoTracker
|
||||
.getOrCreate(context)
|
||||
.windowLayoutInfo(context)
|
||||
} else {
|
||||
WindowInfoTracker
|
||||
.getOrCreate(context)
|
||||
.windowLayoutInfo(context)
|
||||
}.map { it.displayFeatures.filterIsInstance<FoldingFeature>() }
|
||||
}.collectAsState(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* This class collects window info that affects adaptation decisions. An adaptive layout is supposed
|
||||
* to use the info from this class to decide how the layout is supposed to be adapted.
|
||||
*
|
||||
* @constructor create an instance of [WindowAdaptiveInfo]
|
||||
* @param windowSizeClass [WindowSizeClass] of the current window.
|
||||
* @param windowPosture [Posture] of the current window.
|
||||
*/
|
||||
@Immutable
|
||||
class WindowAdaptiveInfo(
|
||||
val windowSizeClass: WindowSizeClass,
|
||||
val windowPosture: Posture,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is WindowAdaptiveInfo) return false
|
||||
if (windowSizeClass != other.windowSizeClass) return false
|
||||
if (windowPosture != other.windowPosture) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = windowSizeClass.hashCode()
|
||||
result = 31 * result + windowPosture.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)"
|
||||
}
|
||||
@@ -32,12 +32,10 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.ots.OpenTimestamps
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -50,15 +48,23 @@ import kotlin.time.measureTimedValue
|
||||
class Amethyst : Application() {
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Service Manager is only active when the activity is active.
|
||||
val serviceManager = ServiceManager(applicationIOScope)
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
applicationIOScope.cancel()
|
||||
}
|
||||
|
||||
fun nip95cache() = safeCacheDir.resolve("NIP95")
|
||||
|
||||
val videoCache: VideoCache by lazy {
|
||||
val newCache = VideoCache()
|
||||
runBlocking {
|
||||
newCache.initFileCache(this@Amethyst)
|
||||
newCache.initFileCache(
|
||||
this@Amethyst,
|
||||
safeCacheDir.resolve("exoplayer"),
|
||||
)
|
||||
}
|
||||
newCache
|
||||
}
|
||||
@@ -66,7 +72,7 @@ class Amethyst : Application() {
|
||||
val coilCache: DiskCache by lazy {
|
||||
DiskCache
|
||||
.Builder()
|
||||
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
|
||||
.directory(safeCacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
|
||||
.build()
|
||||
@@ -75,7 +81,6 @@ class Amethyst : Application() {
|
||||
val coilMemCache: MemoryCache by lazy {
|
||||
MemoryCache
|
||||
.Builder(this)
|
||||
.maxSizePercent(0.40) // memory heavy app due to profile pics and videos.
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -86,7 +91,7 @@ class Amethyst : Application() {
|
||||
|
||||
instance = this
|
||||
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
|
||||
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
|
||||
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
|
||||
StrictMode.setThreadPolicy(
|
||||
@@ -126,6 +131,18 @@ class Amethyst : Application() {
|
||||
|
||||
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
|
||||
|
||||
/**
|
||||
* Release memory when the UI becomes hidden or when system resources become low.
|
||||
*
|
||||
* @param level the memory-related event that was raised.
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: Amethyst
|
||||
private set
|
||||
|
||||
180
amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt
Normal file
180
amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Debug
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import coil.Coil
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.relays.RelayPool
|
||||
|
||||
fun debugState(context: Context) {
|
||||
Client
|
||||
.allSubscriptions()
|
||||
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") }
|
||||
|
||||
NostrAccountDataSource.printCounter()
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatroomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
NostrCommunityDataSource.printCounter()
|
||||
NostrDiscoveryDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
NostrGeohashDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrSearchEventOrUserDataSource.printCounter()
|
||||
NostrSingleChannelDataSource.printCounter()
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
NostrVideoDataSource.printCounter()
|
||||
|
||||
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
|
||||
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
|
||||
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
|
||||
|
||||
val jvmHeapAllocatedMb = totalMemoryMb - freeMemoryMb
|
||||
|
||||
Log.d("STATE DUMP", "Total Heap Allocated: " + jvmHeapAllocatedMb + "/" + maxMemoryMb + " MB")
|
||||
|
||||
val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024)
|
||||
val maxNative = Debug.getNativeHeapSize() / (1024 * 1024)
|
||||
|
||||
Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + "/" + maxNative + " MB")
|
||||
|
||||
val activityManager: ActivityManager? = context.getSystemService()
|
||||
if (activityManager != null) {
|
||||
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
|
||||
val memClass = if (isLargeHeap) activityManager.largeMemoryClass else activityManager.memoryClass
|
||||
|
||||
Log.d("STATE DUMP", "Memory Class " + memClass + " MB (largeHeap $isLargeHeap)")
|
||||
}
|
||||
|
||||
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
|
||||
|
||||
val imageLoader = Coil.imageLoader(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 -> it.liveSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.filter { _, it -> it.event != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.size(),
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Addressables: " +
|
||||
LocalCache.addressables.filter { _, it -> it.liveSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.addressables.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.addressables.filter { _, it -> it.event != null }.size +
|
||||
" / " +
|
||||
LocalCache.addressables.size(),
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Users: " +
|
||||
LocalCache.users.filter { _, it -> it.liveSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.size(),
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Deletion Events: " +
|
||||
LocalCache.deletionIndex.size(),
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Observable Events: " +
|
||||
LocalCache.observablesByKindAndETag.size +
|
||||
" / " +
|
||||
LocalCache.observablesByKindAndAuthor.size,
|
||||
)
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Spam: " +
|
||||
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentMessages.size(),
|
||||
)
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Memory used by Events: " +
|
||||
LocalCache.notes.sumOfLong { _, note -> note.event?.countMemory() ?: 0L } / (1024 * 1024) +
|
||||
" MB",
|
||||
)
|
||||
|
||||
val qttNotes = LocalCache.notes.countByGroup { _, it -> it.event?.kind() }
|
||||
val qttAddressables = LocalCache.addressables.countByGroup { _, it -> it.event?.kind() }
|
||||
|
||||
val bytesNotes =
|
||||
LocalCache.notes
|
||||
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
|
||||
val bytesAddressables =
|
||||
LocalCache.addressables
|
||||
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
|
||||
|
||||
qttNotes.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
|
||||
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ")
|
||||
}
|
||||
qttAddressables.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
|
||||
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.DefaultReactions
|
||||
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
@@ -34,8 +34,10 @@ import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
@@ -47,12 +49,15 @@ import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -70,6 +75,7 @@ data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean,
|
||||
val loggedInWithExternalSigner: Boolean,
|
||||
val isTransient: Boolean,
|
||||
)
|
||||
|
||||
private object PrefKeys {
|
||||
@@ -91,12 +97,17 @@ private object PrefKeys {
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_USER_METADATA = "latestUserMetadata"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
|
||||
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
|
||||
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
|
||||
const val LATEST_MUTE_LIST = "latestMuteList"
|
||||
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
|
||||
const val TOR_SETTINGS = "tor_settings"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
@@ -116,68 +127,85 @@ object LocalPreferences {
|
||||
private const val COMMA = ","
|
||||
|
||||
private var currentAccount: String? = null
|
||||
private var savedAccounts: List<AccountInfo>? = null
|
||||
private var cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
|
||||
private var savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
|
||||
private var cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
|
||||
|
||||
suspend fun currentAccount(): String? {
|
||||
if (currentAccount == null) {
|
||||
currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
currentAccount =
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
}
|
||||
return currentAccount
|
||||
}
|
||||
|
||||
private fun updateCurrentAccount(npub: String?) {
|
||||
if (npub == null) {
|
||||
private suspend fun updateCurrentAccount(info: AccountInfo?) {
|
||||
if (info == null) {
|
||||
currentAccount = null
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
} else if (currentAccount != npub) {
|
||||
currentAccount = npub
|
||||
|
||||
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun savedAccounts(): List<AccountInfo> {
|
||||
if (savedAccounts == null) {
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts =
|
||||
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
Event.mapper.readValue<List<AccountInfo>>(it)
|
||||
}
|
||||
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
savedAccounts = newSystemOfAccounts
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
|
||||
|
||||
val migrated =
|
||||
oldAccounts.map { npub ->
|
||||
AccountInfo(
|
||||
npub,
|
||||
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
|
||||
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "")
|
||||
.isNotBlank(),
|
||||
)
|
||||
}
|
||||
|
||||
savedAccounts = migrated
|
||||
|
||||
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
}
|
||||
} else if (currentAccount != info.npub) {
|
||||
currentAccount = info.npub
|
||||
if (!info.isTransient) {
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) }.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
return savedAccounts!!
|
||||
}
|
||||
|
||||
private suspend fun savedAccounts(): List<AccountInfo> {
|
||||
if (savedAccounts.value == null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts =
|
||||
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
Event.mapper.readValue<List<AccountInfo>>(it)
|
||||
}
|
||||
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
savedAccounts.emit(newSystemOfAccounts)
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
|
||||
|
||||
val migrated =
|
||||
oldAccounts.map { npub ->
|
||||
AccountInfo(
|
||||
npub,
|
||||
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
|
||||
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
savedAccounts.emit(migrated)
|
||||
|
||||
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts.value)) }.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// it's always not null when it gets here.
|
||||
return savedAccounts.value!!
|
||||
}
|
||||
|
||||
fun accountsFlow() = savedAccounts
|
||||
|
||||
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (savedAccounts != accounts) {
|
||||
savedAccounts = accounts
|
||||
savedAccounts.emit(accounts)
|
||||
|
||||
encryptedPreferences()
|
||||
.edit()
|
||||
.apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts)) }
|
||||
.apply()
|
||||
.apply {
|
||||
putString(
|
||||
PrefKeys.ALL_ACCOUNT_INFO,
|
||||
Event.mapper.writeValueAsString(accounts.filter { !it.isTransient }),
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,35 +217,34 @@ object LocalPreferences {
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
|
||||
private suspend fun setCurrentAccount(account: Account) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
val accInfo =
|
||||
AccountInfo(
|
||||
npub,
|
||||
account.isWriteable(),
|
||||
account.signer is NostrSignerExternal,
|
||||
)
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(accInfo)
|
||||
}
|
||||
private suspend fun setCurrentAccount(accountSettings: AccountSettings) {
|
||||
val npub = accountSettings.keyPair.pubKey.toNpub()
|
||||
val accInfo =
|
||||
AccountInfo(
|
||||
npub,
|
||||
accountSettings.isWriteable(),
|
||||
accountSettings.externalSignerPackageName != null,
|
||||
accountSettings.transientAccount,
|
||||
)
|
||||
updateCurrentAccount(accInfo)
|
||||
addAccount(accInfo)
|
||||
}
|
||||
|
||||
suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) { updateCurrentAccount(accountInfo.npub) }
|
||||
suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo)
|
||||
|
||||
/** Removes the account from the app level shared preferences */
|
||||
private suspend fun removeAccount(accountInfo: AccountInfo) {
|
||||
val accounts = savedAccounts().filter { it.npub != accountInfo.npub }
|
||||
updateSavedAccounts(accounts)
|
||||
updateSavedAccounts(savedAccounts().filter { it.npub != accountInfo.npub })
|
||||
}
|
||||
|
||||
/** Deletes the npub-specific shared preference file */
|
||||
private fun deleteUserPreferenceFile(npub: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
private suspend fun deleteUserPreferenceFile(npub: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,132 +271,179 @@ object LocalPreferences {
|
||||
*/
|
||||
@SuppressLint("ApplySharedPref")
|
||||
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout")
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}")
|
||||
withContext(Dispatchers.IO) {
|
||||
val userPrefs = encryptedPreferences(accountInfo.npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
encryptedPreferences(accountInfo.npub).edit().clear().commit()
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
|
||||
if (savedAccounts().isEmpty()) {
|
||||
updateCurrentAccount(null)
|
||||
} else if (currentAccount() == accountInfo.npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0).npub)
|
||||
updateCurrentAccount(savedAccounts().elementAt(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePrefsForLogin(account: Account) {
|
||||
setCurrentAccount(account)
|
||||
saveToEncryptedStorage(account)
|
||||
suspend fun updatePrefsForLogin(accountSettings: AccountSettings) {
|
||||
setCurrentAccount(accountSettings)
|
||||
saveToEncryptedStorage(accountSettings)
|
||||
}
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
|
||||
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
|
||||
|
||||
suspend fun saveToEncryptedStorage(account: Account) {
|
||||
suspend fun saveToEncryptedStorage(settings: AccountSettings) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage")
|
||||
withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal)
|
||||
if (account.signer is NostrSignerExternal) {
|
||||
remove(PrefKeys.NOSTR_PRIVKEY)
|
||||
putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName)
|
||||
} else {
|
||||
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
}
|
||||
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, account.localRelayServers)
|
||||
putString(
|
||||
PrefKeys.LANGUAGE_PREFS,
|
||||
Event.mapper.writeValueAsString(account.languagePreferences),
|
||||
)
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
|
||||
putString(
|
||||
PrefKeys.REACTION_CHOICES,
|
||||
Event.mapper.writeValueAsString(account.reactionChoices),
|
||||
)
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_FILE_SERVER,
|
||||
Event.mapper.writeValueAsString(account.defaultFileServer),
|
||||
)
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
|
||||
account.defaultNotificationFollowList.value,
|
||||
)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
)
|
||||
putString(
|
||||
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
|
||||
Event.mapper.writeValueAsString(account.zapPaymentRequest),
|
||||
)
|
||||
if (account.backupContactList != null) {
|
||||
if (!settings.transientAccount) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub())
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null)
|
||||
if (settings.externalSignerPackageName != null) {
|
||||
remove(PrefKeys.NOSTR_PRIVKEY)
|
||||
putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName)
|
||||
} else {
|
||||
remove(PrefKeys.SIGNER_PACKAGE_NAME)
|
||||
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
}
|
||||
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom)
|
||||
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
|
||||
putString(
|
||||
PrefKeys.LATEST_CONTACT_LIST,
|
||||
Event.mapper.writeValueAsString(account.backupContactList),
|
||||
PrefKeys.LANGUAGE_PREFS,
|
||||
Event.mapper.writeValueAsString(settings.languagePreferences),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_CONTACT_LIST)
|
||||
}
|
||||
|
||||
if (account.backupDMRelayList != null) {
|
||||
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
|
||||
putString(
|
||||
PrefKeys.LATEST_DM_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(account.backupDMRelayList),
|
||||
PrefKeys.REACTION_CHOICES,
|
||||
Event.mapper.writeValueAsString(settings.reactionChoices.value),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_DM_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (account.backupNIP65RelayList != null) {
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
|
||||
putString(
|
||||
PrefKeys.LATEST_NIP65_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(account.backupNIP65RelayList),
|
||||
PrefKeys.DEFAULT_FILE_SERVER,
|
||||
Event.mapper.writeValueAsString(settings.defaultFileServer),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
}
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
|
||||
settings.defaultNotificationFollowList.value,
|
||||
)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
|
||||
settings.defaultDiscoveryFollowList.value,
|
||||
)
|
||||
putString(
|
||||
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
|
||||
Event.mapper.writeValueAsString(settings.zapPaymentRequest),
|
||||
)
|
||||
if (settings.backupContactList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_CONTACT_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupContactList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_CONTACT_LIST)
|
||||
}
|
||||
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, account.hideNIP17WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
|
||||
putString(
|
||||
PrefKeys.LAST_READ_PER_ROUTE,
|
||||
Event.mapper.writeValueAsString(account.lastReadPerRoute),
|
||||
)
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
|
||||
if (settings.backupUserMetadata != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_USER_METADATA,
|
||||
Event.mapper.writeValueAsString(settings.backupUserMetadata),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_USER_METADATA)
|
||||
}
|
||||
|
||||
if (account.showSensitiveContent.value == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent.value!!)
|
||||
}
|
||||
if (settings.backupDMRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_DM_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupDMRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_DM_RELAY_LIST)
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.PENDING_ATTESTATIONS,
|
||||
Event.mapper.writeValueAsString(account.pendingAttestations.value),
|
||||
)
|
||||
}.apply()
|
||||
if (settings.backupNIP65RelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_NIP65_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupNIP65RelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupSearchRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_SEARCH_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupSearchRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupMuteList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_MUTE_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupMuteList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_MUTE_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupPrivateHomeRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
|
||||
}
|
||||
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
|
||||
|
||||
// migrating from previous design
|
||||
remove(PrefKeys.USE_PROXY)
|
||||
remove(PrefKeys.PROXY_PORT)
|
||||
|
||||
putString(PrefKeys.TOR_SETTINGS, Event.mapper.writeValueAsString(settings.torSettings.toSettings()))
|
||||
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers)
|
||||
|
||||
val regularMap =
|
||||
settings.lastReadPerRoute.value.mapValues {
|
||||
it.value.value
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.LAST_READ_PER_ROUTE,
|
||||
Event.mapper.writeValueAsString(regularMap),
|
||||
)
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
|
||||
|
||||
if (settings.showSensitiveContent.value == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!)
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.PENDING_ATTESTATIONS,
|
||||
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
|
||||
suspend fun saveSharedSettings(
|
||||
sharedSettings: Settings,
|
||||
@@ -402,39 +476,39 @@ object LocalPreferences {
|
||||
|
||||
val mutex = Mutex()
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? =
|
||||
withContext(Dispatchers.IO) {
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? {
|
||||
// if already loaded, return right away
|
||||
if (cachedAccounts.containsKey(npub)) {
|
||||
return cachedAccounts[npub]
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
if (cachedAccounts.containsKey(npub)) {
|
||||
return@withContext cachedAccounts.get(npub)
|
||||
}
|
||||
|
||||
val account = innerLoadCurrentAccountFromEncryptedStorage(npub)
|
||||
account?.registerObservers()
|
||||
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
|
||||
|
||||
cachedAccounts.put(npub, account)
|
||||
cachedAccounts.put(npub, accountSettings)
|
||||
|
||||
return@withContext account
|
||||
return@withContext accountSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? {
|
||||
Log.d("LocalPreferences", "Load account from file")
|
||||
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
|
||||
Log.d("LocalPreferences", "Load account from file $npub")
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
|
||||
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
|
||||
val localRelays =
|
||||
getString(PrefKeys.RELAYS, "[]")?.let {
|
||||
println("LocalRelays: $it")
|
||||
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
|
||||
}
|
||||
?: setOf<RelaySetupInfo>()
|
||||
val externalSignerPackageName =
|
||||
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
|
||||
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
@@ -448,156 +522,54 @@ 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) }
|
||||
?.ifEmpty { DefaultZapAmounts }
|
||||
?: DefaultZapAmounts
|
||||
|
||||
val reactionChoices =
|
||||
getString(PrefKeys.REACTION_CHOICES, "[]")
|
||||
?.let { Event.mapper.readValue<List<String>?>(it) }
|
||||
?.ifEmpty { DefaultReactions }
|
||||
?: DefaultReactions
|
||||
|
||||
val defaultZapType =
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
|
||||
LnZapEvent.ZapType.values().firstOrNull { it.name == serverName }
|
||||
}
|
||||
?: LnZapEvent.ZapType.PUBLIC
|
||||
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer =
|
||||
try {
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
|
||||
}
|
||||
?: Nip96MediaServers.DEFAULT[0]
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
|
||||
e.printStackTrace()
|
||||
Nip96MediaServers.DEFAULT[0]
|
||||
}
|
||||
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
|
||||
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
|
||||
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
|
||||
|
||||
val zapPaymentRequestServer =
|
||||
try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
Event.mapper.readValue<Nip47WalletConnect.Nip47URI?>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
val defaultFileServer = parseOrNull<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0]
|
||||
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
||||
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
|
||||
|
||||
val latestContactList =
|
||||
try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
if (it != "null") {
|
||||
println("Decoding Contact List: $it")
|
||||
Event.fromJson(it) as ContactListEvent?
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val latestDmRelayList =
|
||||
try {
|
||||
getString(PrefKeys.LATEST_DM_RELAY_LIST, null)?.let {
|
||||
if (it != "null") {
|
||||
println("Decoding DM Relay List: $it")
|
||||
Event.fromJson(it) as ChatMessageRelayListEvent?
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding DM Relay List ${getString(PrefKeys.LATEST_DM_RELAY_LIST, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val latestNip65RelayList =
|
||||
try {
|
||||
getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)?.let {
|
||||
if (it != "null") {
|
||||
println("Decoding NIP65 Relay List: $it")
|
||||
Event.fromJson(it) as AdvertisedRelayListEvent?
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding NIP65 Relay List ${getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val pendingAttestations =
|
||||
try {
|
||||
getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let {
|
||||
println("Decoding Attestation List: " + it)
|
||||
if (it != null) {
|
||||
Event.mapper.readValue<Map<HexKey, String>>(it)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Contact List ${getString(PrefKeys.PENDING_ATTESTATIONS, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences =
|
||||
try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
Event.mapper.readValue<Map<String, String>?>(it)
|
||||
}
|
||||
?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
|
||||
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
|
||||
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
|
||||
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
|
||||
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
|
||||
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val torSettings =
|
||||
if (useProxy) {
|
||||
// old settings, means Orbot
|
||||
TorSettings(
|
||||
TorType.EXTERNAL,
|
||||
getInt(PrefKeys.PROXY_PORT, 9050),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
|
||||
}
|
||||
|
||||
val showSensitiveContent =
|
||||
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
@@ -609,81 +581,84 @@ object LocalPreferences {
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val lastReadPerRoute =
|
||||
try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
Event.mapper.readValue<Map<String, Long>?>(it)
|
||||
}
|
||||
?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
|
||||
MutableStateFlow(it.value)
|
||||
} ?: mapOf()
|
||||
|
||||
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
|
||||
val signer =
|
||||
if (loginWithExternalSigner) {
|
||||
val packageName =
|
||||
getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner"
|
||||
NostrSignerExternal(
|
||||
pubKey,
|
||||
ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName),
|
||||
)
|
||||
} else {
|
||||
NostrSignerInternal(keyPair)
|
||||
}
|
||||
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
|
||||
val account =
|
||||
Account(
|
||||
keyPair = keyPair,
|
||||
signer = signer,
|
||||
localRelays = localRelays,
|
||||
localRelayServers = localRelayServers,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
zapAmountChoices = zapAmountChoices,
|
||||
reactionChoices = reactionChoices,
|
||||
defaultZapType = defaultZapType,
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
backupContactList = latestContactList,
|
||||
backupNIP65RelayList = latestNip65RelayList,
|
||||
backupDMRelayList = latestDmRelayList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
showSensitiveContent = MutableStateFlow(showSensitiveContent),
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
hasDonatedInVersion = hasDonatedInVersion,
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations ?: emptyMap()),
|
||||
)
|
||||
|
||||
// Loads from DB
|
||||
account.userProfile()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Loads Live Objects
|
||||
account.userProfile().live()
|
||||
}
|
||||
|
||||
return@with account
|
||||
return@with AccountSettings(
|
||||
keyPair = keyPair,
|
||||
transientAccount = false,
|
||||
externalSignerPackageName = externalSignerPackageName,
|
||||
localRelays = localRelays,
|
||||
localRelayServers = localRelayServers,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
zapAmountChoices = MutableStateFlow(zapAmountChoices.toImmutableList()),
|
||||
reactionChoices = MutableStateFlow(reactionChoices.toImmutableList()),
|
||||
defaultZapType = MutableStateFlow(defaultZapType),
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
backupUserMetadata = latestUserMetadata,
|
||||
backupContactList = latestContactList,
|
||||
backupNIP65RelayList = latestNip65RelayList,
|
||||
backupDMRelayList = latestDmRelayList,
|
||||
backupSearchRelayList = latestSearchRelayList,
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
|
||||
backupMuteList = latestMuteList,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
showSensitiveContent = MutableStateFlow(showSensitiveContent),
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> SharedPreferences.parseOrNull(key: String): T? {
|
||||
val value = getString(key, null)
|
||||
if (value.isNullOrEmpty() || value == "null") {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
if (T::class.java.isInstance(Event::class.java)) {
|
||||
Event.fromJson(value) as T?
|
||||
} else {
|
||||
Event.mapper.readValue<T?>(value)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> SharedPreferences.parseEventOrNull(key: String): T? {
|
||||
val value = getString(key, null)
|
||||
if (value.isNullOrEmpty() || value == "null") {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
Event.fromJson(value) as T?
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.size.Precision
|
||||
import coil.util.DebugLogger
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.Base64Fetcher
|
||||
@@ -47,28 +48,35 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.ots.OpenTimestamps
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@Stable
|
||||
class ServiceManager {
|
||||
class ServiceManager(
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
private var isStarted: Boolean =
|
||||
false // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var account: Account? = null
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var collectorJob: Job? = null
|
||||
|
||||
private fun start(account: Account) {
|
||||
@@ -86,9 +94,28 @@ class ServiceManager {
|
||||
val myAccount = account
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClientManager.setDefaultProxy(account?.proxy)
|
||||
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
|
||||
if (myAccount != null) {
|
||||
when (myAccount.settings.torSettings.torType.value) {
|
||||
TorType.INTERNAL -> {
|
||||
Log.d("TorManager", "Relays Service Connected ${TorManager.socksPort()}")
|
||||
HttpClientManager.setDefaultProxyOnPort(TorManager.socksPort())
|
||||
}
|
||||
TorType.EXTERNAL -> HttpClientManager.setDefaultProxyOnPort(myAccount.settings.torSettings.externalSocksPort.value)
|
||||
else -> HttpClientManager.setDefaultProxy(null)
|
||||
}
|
||||
|
||||
OtsEvent.otsInstance =
|
||||
OpenTimestamps(
|
||||
OkHttpBlockstreamExplorer(myAccount::shouldUseTorForMoneyOperations),
|
||||
OkHttpCalendarBuilder(myAccount::shouldUseTorForMoneyOperations),
|
||||
)
|
||||
} else {
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer { false }, OkHttpCalendarBuilder { false })
|
||||
|
||||
HttpClientManager.setDefaultProxy(null)
|
||||
}
|
||||
|
||||
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
|
||||
Coil.setImageLoader {
|
||||
Amethyst.instance
|
||||
.imageLoaderBuilder()
|
||||
@@ -100,23 +127,27 @@ class ServiceManager {
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
add(Base64Fetcher.Factory)
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClientManager.getHttpClient() }
|
||||
.precision(Precision.INEXACT)
|
||||
}.apply {
|
||||
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
|
||||
this.logger(DebugLogger())
|
||||
}
|
||||
}.okHttpClient {
|
||||
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
|
||||
?: HttpClientManager.getHttpClient(false)
|
||||
}.precision(Precision.INEXACT)
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
if (myAccount != null) {
|
||||
val relaySet = myAccount.connectToRelays.value
|
||||
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
|
||||
val relaySet = myAccount.connectToRelaysWithProxy.value
|
||||
Client.reconnect(relaySet)
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
collectorJob =
|
||||
scope.launch {
|
||||
myAccount.connectToRelaysFlow.collectLatest {
|
||||
myAccount.connectToRelaysWithProxy.collectLatest {
|
||||
delay(500)
|
||||
if (isStarted) {
|
||||
Client.reconnect(it, onlyIfChanged = true)
|
||||
@@ -126,20 +157,23 @@ class ServiceManager {
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrAccountDataSource.otherAccounts =
|
||||
LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
try {
|
||||
it.npub.bechToBytes().toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
NostrDiscoveryDataSource.account = myAccount
|
||||
|
||||
NostrAccountDataSource.otherAccounts =
|
||||
runBlocking {
|
||||
LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
try {
|
||||
it.npub.bechToBytes().toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
@@ -190,7 +224,7 @@ class ServiceManager {
|
||||
LocalCache.cleanObservers()
|
||||
}
|
||||
|
||||
fun trimMemory() {
|
||||
suspend fun trimMemory() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
val accounts =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.ammolite.relays.Constants
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels =
|
||||
setOf(
|
||||
// Anigma's Nostr
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
|
||||
// Amethyst's Group
|
||||
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
|
||||
)
|
||||
|
||||
val DefaultReactions =
|
||||
persistentListOf(
|
||||
"\uD83D\uDE80",
|
||||
"\uD83E\uDEC2",
|
||||
"\uD83D\uDC40",
|
||||
"\uD83D\uDE02",
|
||||
"\uD83C\uDF89",
|
||||
"\uD83E\uDD14",
|
||||
"\uD83D\uDE31",
|
||||
)
|
||||
|
||||
val DefaultNIP65List =
|
||||
listOf(
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
)
|
||||
|
||||
val DefaultDMRelayList =
|
||||
listOf(
|
||||
RelayUrlFormatter.normalize("wss://auth.nostr1.com/"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.mom/"),
|
||||
RelayUrlFormatter.normalize("wss://nos.lol/"),
|
||||
)
|
||||
|
||||
val DefaultSearchRelayList =
|
||||
listOf(
|
||||
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.wine"),
|
||||
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
|
||||
)
|
||||
|
||||
val DefaultZapAmounts = persistentListOf(100L, 500L, 1000L)
|
||||
|
||||
fun getLanguagesSpokenByUser(): Set<String> {
|
||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
val codedList = mutableSetOf<String>()
|
||||
for (i in 0 until languageList.size()) {
|
||||
languageList.get(i)?.let { codedList.add(it.language) }
|
||||
}
|
||||
return codedList
|
||||
}
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
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 "
|
||||
|
||||
@Stable
|
||||
class AccountSettings(
|
||||
val keyPair: KeyPair,
|
||||
val transientAccount: Boolean = false,
|
||||
var externalSignerPackageName: String? = null,
|
||||
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
|
||||
var localRelayServers: Set<String> = setOf(),
|
||||
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>> = MutableStateFlow(DefaultZapAmounts),
|
||||
var reactionChoices: MutableStateFlow<ImmutableList<String>> = MutableStateFlow(DefaultReactions),
|
||||
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType> = MutableStateFlow(LnZapEvent.ZapType.PUBLIC),
|
||||
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
|
||||
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
|
||||
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
var backupUserMetadata: MetadataEvent? = null,
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var backupDMRelayList: ChatMessageRelayListEvent? = null,
|
||||
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
|
||||
var backupSearchRelayList: SearchRelayListEvent? = null,
|
||||
var backupMuteList: MuteListEvent? = null,
|
||||
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
|
||||
val torSettings: TorSettingsFlow = TorSettingsFlow(),
|
||||
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
|
||||
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
|
||||
) {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(this))
|
||||
|
||||
class AccountSettingsUpdater(
|
||||
val accountSettings: AccountSettings,
|
||||
)
|
||||
|
||||
fun saveAccountSettings() {
|
||||
saveable.update { AccountSettingsUpdater(this) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
|
||||
|
||||
fun createSigner() =
|
||||
if (keyPair.privKey != null) {
|
||||
NostrSignerInternal(keyPair)
|
||||
} else {
|
||||
when (val packageName = externalSignerPackageName) {
|
||||
null -> NostrSignerInternal(keyPair)
|
||||
else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName))
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// Zaps and Reactions
|
||||
// ---
|
||||
|
||||
fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
|
||||
if (defaultZapType.value != zapType) {
|
||||
defaultZapType.tryEmit(zapType)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
if (zapAmountChoices.value != newAmounts) {
|
||||
zapAmountChoices.tryEmit(newAmounts.toImmutableList())
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
|
||||
if (zapPaymentRequest != newServer) {
|
||||
zapPaymentRequest = newServer
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeReactionTypes(newTypes: List<String>) {
|
||||
if (reactionChoices.value != newTypes) {
|
||||
reactionChoices.tryEmit(newTypes.toImmutableList())
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// file servers
|
||||
// ---
|
||||
|
||||
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
|
||||
if (defaultFileServer != server) {
|
||||
defaultFileServer = server
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// list names
|
||||
// ---
|
||||
|
||||
fun changeDefaultHomeFollowList(name: String) {
|
||||
if (defaultHomeFollowList.value != name) {
|
||||
defaultHomeFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultStoriesFollowList(name: String) {
|
||||
if (defaultStoriesFollowList.value != name) {
|
||||
defaultStoriesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultNotificationFollowList(name: String) {
|
||||
if (defaultNotificationFollowList.value != name) {
|
||||
defaultNotificationFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultDiscoveryFollowList(name: String) {
|
||||
if (defaultDiscoveryFollowList.value != name) {
|
||||
defaultDiscoveryFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// proxy settings
|
||||
// ---
|
||||
fun setTorSettings(newTorSettings: TorSettings): Boolean {
|
||||
if (torSettings.update(newTorSettings)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
fun toggleDontTranslateFrom(languageCode: String) {
|
||||
if (!dontTranslateFrom.contains(languageCode)) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
saveAccountSettings()
|
||||
} else {
|
||||
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale) {
|
||||
if (translateTo != languageCode.language) {
|
||||
translateTo = languageCode.language
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun prefer(
|
||||
source: String,
|
||||
target: String,
|
||||
preference: String,
|
||||
) {
|
||||
val key = "$source,$target"
|
||||
if (key !in languagePreferences) {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
saveAccountSettings()
|
||||
} else {
|
||||
if (languagePreferences.get(key) == preference) {
|
||||
languagePreferences = languagePreferences.minus(key)
|
||||
} else {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun preferenceBetween(
|
||||
source: String,
|
||||
target: String,
|
||||
): String? = languagePreferences["$source,$target"]
|
||||
|
||||
// ----
|
||||
// Backup Lists
|
||||
// ----
|
||||
|
||||
fun updateLocalRelayServers(servers: Set<String>) {
|
||||
if (localRelayServers != servers) {
|
||||
localRelayServers = servers
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUserMetadata(newMetadata: MetadataEvent?) {
|
||||
if (newMetadata == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupUserMetadata?.id != newMetadata.id) {
|
||||
backupUserMetadata = newMetadata
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateContactListTo(newContactList: ContactListEvent?) {
|
||||
if (newContactList == null || newContactList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupContactList?.id != newContactList.id) {
|
||||
backupContactList = newContactList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
|
||||
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupDMRelayList?.id != newDMRelayList.id) {
|
||||
backupDMRelayList = newDMRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
|
||||
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
|
||||
backupNIP65RelayList = newNIP65RelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
|
||||
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupSearchRelayList?.id != newSearchRelayList.id) {
|
||||
backupSearchRelayList = newSearchRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
|
||||
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
|
||||
backupPrivateHomeRelayList = newPrivateHomeRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMuteList(newMuteList: MuteListEvent?) {
|
||||
if (newMuteList == null || newMuteList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupMuteList?.id != newMuteList.id) {
|
||||
backupMuteList = newMuteList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// Warning dialogs
|
||||
// ----
|
||||
|
||||
fun setHideDeleteRequestDialog() {
|
||||
if (!hideDeleteRequestDialog) {
|
||||
hideDeleteRequestDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideNIP17WarningDialog() {
|
||||
if (!hideNIP17WarningDialog) {
|
||||
hideNIP17WarningDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideBlockAlertDialog() {
|
||||
if (!hideBlockAlertDialog) {
|
||||
hideBlockAlertDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
showSensitiveContent.update { show }
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---
|
||||
// donations
|
||||
// ---
|
||||
|
||||
fun hasDonatedInVersion(versionName: String) = hasDonatedInVersion.value.contains(versionName)
|
||||
|
||||
fun observeDonatedInVersion(versionName: String) =
|
||||
hasDonatedInVersion
|
||||
.map {
|
||||
it.contains(versionName)
|
||||
}
|
||||
|
||||
fun markDonatedInThisVersion(versionName: String): Boolean {
|
||||
if (!hasDonatedInVersion.value.contains(versionName)) {
|
||||
hasDonatedInVersion.update {
|
||||
it + versionName
|
||||
}
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ----
|
||||
// last read flows
|
||||
// ----
|
||||
|
||||
fun getLastReadFlow(route: String): StateFlow<Long> = lastReadPerRoute.value[route] ?: addLastRead(route, 0)
|
||||
|
||||
private fun addLastRead(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
): MutableStateFlow<Long> =
|
||||
MutableStateFlow<Long>(timestampInSecs).also { newFlow ->
|
||||
lastReadPerRoute.update { it + Pair(route, newFlow) }
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun markAsRead(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
): Boolean {
|
||||
val lastTime = lastReadPerRoute.value[route]
|
||||
return if (lastTime == null) {
|
||||
addLastRead(route, timestampInSecs)
|
||||
true
|
||||
} else if (timestampInSecs > lastTime.value) {
|
||||
lastTime.tryEmit(timestampInSecs)
|
||||
saveAccountSettings()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// local relays
|
||||
// ----
|
||||
|
||||
fun updateLocalRelays(newLocalRelays: Set<RelaySetupInfo>) {
|
||||
if (!localRelays.equals(newLocalRelays)) {
|
||||
localRelays = newLocalRelays
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// attestations
|
||||
// ---
|
||||
|
||||
fun addPendingAttestation(
|
||||
id: HexKey,
|
||||
stamp: String,
|
||||
) {
|
||||
val current = pendingAttestations.value.get(id)
|
||||
if (current == null) {
|
||||
pendingAttestations.update {
|
||||
it + Pair(id, stamp)
|
||||
}
|
||||
saveAccountSettings()
|
||||
} else {
|
||||
if (current != stamp) {
|
||||
pendingAttestations.update {
|
||||
it + Pair(id, stamp)
|
||||
}
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// filters
|
||||
// ---
|
||||
fun updateOptOutOptions(
|
||||
warnReports: Boolean,
|
||||
filterSpam: Boolean,
|
||||
): Boolean =
|
||||
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
|
||||
saveAccountSettings()
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,14 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.ammolite.relays.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.njumpLink
|
||||
import com.vitorpamplona.ammolite.relays.Relay
|
||||
import com.vitorpamplona.ammolite.relays.RelayStats
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
data class Spammer(
|
||||
val pubkeyHex: HexKey,
|
||||
@@ -82,10 +80,10 @@ class AntiSpamFilter {
|
||||
logOffender(hash, event)
|
||||
|
||||
if (relay != null) {
|
||||
RelayStats.newSpam(relay.url, "https://njump.me/${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)}")
|
||||
RelayStats.newSpam(relay.url, njumpLink(Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)))
|
||||
}
|
||||
|
||||
liveSpam.invalidateData()
|
||||
flowSpam.tryEmit(AntiSpamState(this))
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -108,27 +106,7 @@ class AntiSpamFilter {
|
||||
}
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AntiSpamLiveData(
|
||||
val cache: AntiSpamFilter,
|
||||
) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
|
||||
}
|
||||
|
||||
class AntiSpamState(
|
||||
|
||||
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Stable
|
||||
@@ -75,7 +77,7 @@ class Chatroom {
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean = authors.any { it.pubkeyHex in keySet }
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
val sorted = roomMessages.sortedWith(DefaultFeedOrder)
|
||||
|
||||
val toKeep =
|
||||
if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
|
||||
@@ -84,7 +86,7 @@ class Chatroom {
|
||||
} else {
|
||||
// Old messages, keep the last one.
|
||||
sorted.take(1).toSet()
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false }
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent }
|
||||
|
||||
val toRemove = roomMessages.minus(toKeep)
|
||||
roomMessages = toKeep
|
||||
|
||||
@@ -47,21 +47,20 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.ui.components.HashTag
|
||||
import com.vitorpamplona.amethyst.ui.components.RenderRegular
|
||||
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderHashTagIcons() {
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
fun RenderHashTagIconsPreview() {
|
||||
ThemeComparisonColumn {
|
||||
RenderRegular(
|
||||
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate",
|
||||
EmptyTagList,
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
is HashTagSegment -> HashTag(word, EmptyNav)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.VerificationState
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
@@ -463,6 +466,61 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: TorrentEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: TorrentCommentEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryResponseEvent,
|
||||
relay: Relay? = null,
|
||||
@@ -795,6 +853,8 @@ object LocalCache {
|
||||
.tagsWithoutCitations()
|
||||
.filter { it != event.activity()?.toTag() }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
is TorrentCommentEvent ->
|
||||
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
is DraftEvent -> {
|
||||
event.mapTaggedEvent { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) }
|
||||
@@ -1034,7 +1094,7 @@ object LocalCache {
|
||||
if (version.event?.id() == event.id()) return
|
||||
|
||||
// makes sure the OTS has a valid certificate
|
||||
if (event.cacheVerify() == null) return // no valid OTS
|
||||
if (event.cacheVerify() is VerificationState.Error) return // no valid OTS
|
||||
|
||||
if (version.event == null) {
|
||||
version.loadEvent(event, author, emptyList())
|
||||
@@ -1303,6 +1363,10 @@ object LocalCache {
|
||||
getChannelIfExists(it.toTag())?.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
(deletedEvent as? TorrentCommentEvent)?.torrent()?.let {
|
||||
getNoteIfExists(it)?.removeReply(deleteNote)
|
||||
}
|
||||
|
||||
if (deletedEvent is PrivateDmEvent) {
|
||||
val author = deleteNote.author
|
||||
val recipient =
|
||||
@@ -1328,6 +1392,9 @@ object LocalCache {
|
||||
deleteWraps(deletedEvent)
|
||||
}
|
||||
|
||||
deleteNote.clearFlow()
|
||||
deleteNote.clearLive()
|
||||
|
||||
notes.remove(deleteNote.idHex)
|
||||
}
|
||||
|
||||
@@ -1339,7 +1406,10 @@ object LocalCache {
|
||||
if (noteEvent is WrappedEvent) {
|
||||
deleteWraps(noteEvent)
|
||||
}
|
||||
it.clearFlow()
|
||||
it.clearLive()
|
||||
}
|
||||
|
||||
notes.remove(it.id)
|
||||
}
|
||||
}
|
||||
@@ -1595,7 +1665,10 @@ object LocalCache {
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: ChannelMuteUserEvent) {}
|
||||
|
||||
fun consume(event: LnZapEvent) {
|
||||
fun consume(
|
||||
event: LnZapEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
@@ -1804,7 +1877,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.cacheDir, "NIP95")
|
||||
val cachePath = Amethyst.instance.nip95cache()
|
||||
cachePath.mkdirs()
|
||||
val file = File(cachePath, event.id)
|
||||
if (!file.exists()) {
|
||||
@@ -2083,7 +2156,7 @@ object LocalCache {
|
||||
notes.forEach { _, item ->
|
||||
val noteEvent = item.event
|
||||
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
|
||||
noteEvent.verifiedTime?.let { stampedTime ->
|
||||
(noteEvent.cacheVerify() as? VerificationState.Verified)?.verifiedTime?.let { stampedTime ->
|
||||
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
|
||||
minTime = stampedTime
|
||||
}
|
||||
@@ -2114,7 +2187,7 @@ object LocalCache {
|
||||
.filter { _, item ->
|
||||
val noteEvent = item.event
|
||||
|
||||
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
|
||||
noteEvent is TextNoteModificationEvent && note.author == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
|
||||
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
|
||||
modificationCache.put(note.idHex, newNotes)
|
||||
@@ -2178,6 +2251,8 @@ object LocalCache {
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
// TODO: NEED TO TEST IF WRAPS COME BACK WHEN NEEDED BEFORE ACTIVATING
|
||||
// childrenToBeRemoved.addAll(removeIfWrap(it))
|
||||
removeFromCache(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
@@ -2187,13 +2262,31 @@ object LocalCache {
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} private messages with ${user.toBestDisplayName()} removed. ${it.roomMessages.size} kept",
|
||||
"PRUNE: ${toBeRemoved.size} private messages from ${user.toBestDisplayName()} to ${it.authors.joinToString(", ") { it.toBestDisplayName() }} removed. ${it.roomMessages.size} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeIfWrap(note: Note): List<Note> {
|
||||
val noteEvent = note.event
|
||||
|
||||
val children =
|
||||
if (noteEvent is WrappedEvent) {
|
||||
noteEvent.host?.id?.let {
|
||||
getNoteIfExists(it)?.let {
|
||||
removeFromCache(it)
|
||||
it.removeAllChildNotes()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return children ?: emptyList()
|
||||
}
|
||||
|
||||
fun prunePastVersionsOfReplaceables() {
|
||||
val toBeRemoved =
|
||||
notes.filter { _, note ->
|
||||
@@ -2302,6 +2395,9 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
note.clearFlow()
|
||||
note.clearLive()
|
||||
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
@@ -2601,7 +2697,7 @@ object LocalCache {
|
||||
event.zapRequest?.let {
|
||||
// must have a valid request
|
||||
verifyAndConsume(it, relay)
|
||||
consume(event)
|
||||
consume(event, relay)
|
||||
}
|
||||
}
|
||||
is LnZapRequestEvent -> consume(event)
|
||||
@@ -2635,6 +2731,8 @@ object LocalCache {
|
||||
is StatusEvent -> consume(event, relay)
|
||||
is TextNoteEvent -> consume(event, relay)
|
||||
is TextNoteModificationEvent -> consume(event, relay)
|
||||
is TorrentEvent -> consume(event, relay)
|
||||
is TorrentCommentEvent -> consume(event, relay)
|
||||
is VideoHorizontalEvent -> consume(event, relay)
|
||||
is VideoVerticalEvent -> consume(event, relay)
|
||||
is WikiNoteEvent -> consume(event, relay)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
@@ -40,11 +41,13 @@ import kotlinx.collections.immutable.toImmutableMap
|
||||
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
|
||||
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
|
||||
)
|
||||
@Stable
|
||||
open class Resource(
|
||||
var resourceType: String? = null,
|
||||
var id: String = "",
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Practitioner(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -53,6 +56,7 @@ class Practitioner(
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class Patient(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -61,6 +65,7 @@ class Patient(
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class HumanName(
|
||||
var use: String? = null,
|
||||
var family: String? = null,
|
||||
@@ -69,6 +74,7 @@ class HumanName(
|
||||
fun assembleName(): String = given.joinToString(" ") + " " + family
|
||||
}
|
||||
|
||||
@Stable
|
||||
class Bundle(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -77,6 +83,7 @@ class Bundle(
|
||||
var entry: List<Resource> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class VisionPrescription(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -87,15 +94,31 @@ class VisionPrescription(
|
||||
var dateWritten: String? = null,
|
||||
var prescriber: Reference? = Reference(),
|
||||
var lensSpecification: List<LensSpecification> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
) : Resource(resourceType, id) {
|
||||
fun glasses() = lensSpecification.filter { it.product == "lens" }
|
||||
|
||||
fun contacts() = lensSpecification.filter { it.product == "contacts" }
|
||||
|
||||
fun glassesRightEyes() = lensSpecification.filter { it.product == "lens" && it.eye == "right" }
|
||||
|
||||
fun glassesLeftEyes() = lensSpecification.filter { it.product == "lens" && it.eye == "left" }
|
||||
|
||||
fun contactsRightEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "right" }
|
||||
|
||||
fun contactsLeftEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "left" }
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LensSpecification(
|
||||
var product: String? = null,
|
||||
var eye: String? = null,
|
||||
var sphere: Double? = null,
|
||||
var cylinder: Double? = null,
|
||||
var axis: Double? = null,
|
||||
var pd: Double? = null,
|
||||
var interAdd: Double? = null,
|
||||
var add: Double? = null,
|
||||
var prism: List<Prism> = arrayListOf(),
|
||||
var prism: Prism? = null,
|
||||
// contact lenses
|
||||
var power: Double? = null,
|
||||
var backCurve: Double? = null,
|
||||
@@ -105,6 +128,7 @@ class LensSpecification(
|
||||
var note: String? = null,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Prism(
|
||||
var amount: Double? = null,
|
||||
var base: String? = null,
|
||||
|
||||
@@ -67,7 +67,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.math.BigDecimal
|
||||
@@ -764,8 +766,32 @@ open class Note(
|
||||
}
|
||||
}
|
||||
|
||||
if (thisEvent is BaseTextNoteEvent) {
|
||||
if (accountChoices.hiddenWordsCase.isNotEmpty() && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) {
|
||||
if (accountChoices.hiddenWordsCase.isNotEmpty()) {
|
||||
if (thisEvent is BaseTextNoteEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (author?.toBestDisplayName()?.containsAny(accountChoices.hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (author?.profilePicture()?.containsAny(accountChoices.hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (author?.info?.banner?.containsAny(accountChoices.hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (author?.info?.about?.containsAny(accountChoices.hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (author?.info?.lud06?.containsAny(accountChoices.hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (author?.info?.lud16?.containsAny(accountChoices.hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -848,6 +874,15 @@ class NoteFlowSet(
|
||||
val reports = NoteBundledRefresherFlow(u)
|
||||
val relays = NoteBundledRefresherFlow(u)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun author() =
|
||||
metadata.stateFlow.flatMapLatest {
|
||||
it.note.author
|
||||
?.flow()
|
||||
?.metadata
|
||||
?.stateFlow ?: MutableStateFlow(null)
|
||||
}
|
||||
|
||||
fun isInUse(): Boolean =
|
||||
metadata.stateFlow.subscriptionCount.value > 0 ||
|
||||
reports.stateFlow.subscriptionCount.value > 0 ||
|
||||
|
||||
@@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
||||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
|
||||
@Stable
|
||||
@@ -34,6 +32,7 @@ object UrlCachedPreviewer {
|
||||
|
||||
suspend fun previewInfo(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
onReady: suspend (UrlPreviewState) -> Unit,
|
||||
) {
|
||||
cache[url]?.let {
|
||||
@@ -41,39 +40,37 @@ object UrlCachedPreviewer {
|
||||
return
|
||||
}
|
||||
|
||||
BahaUrlPreview(
|
||||
UrlPreview().fetch(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override suspend fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val state =
|
||||
if (urlInfo.fetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
|
||||
override suspend fun onFailed(throwable: Throwable) {
|
||||
cache[url]?.let {
|
||||
forceProxy,
|
||||
onComplete = { urlInfo ->
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return
|
||||
return@fetch
|
||||
}
|
||||
}
|
||||
|
||||
val state =
|
||||
if (urlInfo.fetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
},
|
||||
).fetchUrlPreview()
|
||||
onFailed = { throwable ->
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return@fetch
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,7 @@ class User(
|
||||
}
|
||||
}
|
||||
|
||||
flowSet?.metadata?.invalidateData()
|
||||
liveSet?.innerMetadata?.invalidateData()
|
||||
}
|
||||
|
||||
@@ -434,12 +435,17 @@ class UserFlowSet(
|
||||
u: User,
|
||||
) {
|
||||
// Observers line up here.
|
||||
val metadata = UserBundledRefresherFlow(u)
|
||||
val follows = UserBundledRefresherFlow(u)
|
||||
val relays = UserBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean = relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
|
||||
fun isInUse(): Boolean =
|
||||
metadata.stateFlow.subscriptionCount.value > 0 ||
|
||||
relays.stateFlow.subscriptionCount.value > 0 ||
|
||||
follows.stateFlow.subscriptionCount.value > 0
|
||||
|
||||
fun destroy() {
|
||||
metadata.destroy()
|
||||
relays.destroy()
|
||||
follows.destroy()
|
||||
}
|
||||
@@ -460,7 +466,6 @@ class UserLiveSet(
|
||||
val innerRelayInfo = UserBundledRefresherLiveData(u)
|
||||
val innerZaps = UserBundledRefresherLiveData(u)
|
||||
val innerBookmarks = UserBundledRefresherLiveData(u)
|
||||
val innerGallery = UserBundledRefresherLiveData(u)
|
||||
val innerStatuses = UserBundledRefresherLiveData(u)
|
||||
|
||||
// UI Observers line up here.
|
||||
@@ -473,7 +478,6 @@ class UserLiveSet(
|
||||
val relayInfo = innerRelayInfo.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
val bookmarks = innerBookmarks.map { it }
|
||||
val gallery = innerGallery.map { it }
|
||||
val statuses = innerStatuses.map { it }
|
||||
|
||||
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
|
||||
@@ -492,7 +496,6 @@ class UserLiveSet(
|
||||
relayInfo.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
bookmarks.hasObservers() ||
|
||||
gallery.hasObservers() ||
|
||||
statuses.hasObservers() ||
|
||||
profilePictureChanges.hasObservers() ||
|
||||
nip05Changes.hasObservers() ||
|
||||
@@ -508,7 +511,6 @@ class UserLiveSet(
|
||||
innerRelayInfo.destroy()
|
||||
innerZaps.destroy()
|
||||
innerBookmarks.destroy()
|
||||
innerGallery.destroy()
|
||||
innerStatuses.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,9 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<Int, RichTextViewerState>(50)
|
||||
private val richTextCache = LruCache<Int, RichTextViewerState>(50)
|
||||
|
||||
// fun getCached(content: String): RichTextViewerState? = richTextCache[content]
|
||||
|
||||
fun hashCodeCache(
|
||||
private fun hashCodeCache(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String?,
|
||||
|
||||
@@ -32,6 +32,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.ByteString
|
||||
@@ -61,11 +63,11 @@ class Proof(
|
||||
)
|
||||
|
||||
object CachedCashuProcessor {
|
||||
val cashuCache = LruCache<String, GenericLoadable<List<CashuToken>>>(20)
|
||||
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
|
||||
|
||||
fun cached(token: String): GenericLoadable<List<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
|
||||
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
|
||||
|
||||
fun parse(token: String): GenericLoadable<List<CashuToken>> {
|
||||
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
if (cashuCache[token] !is GenericLoadable.Loaded) {
|
||||
checkNotInMainThread()
|
||||
val newCachuData = CashuProcessor().parse(token)
|
||||
@@ -91,7 +93,7 @@ class CashuProcessor {
|
||||
val proofs: List<Proof>,
|
||||
)
|
||||
|
||||
fun parse(cashuToken: String): GenericLoadable<List<CashuToken>> {
|
||||
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (cashuToken.startsWith("cashuA")) {
|
||||
@@ -105,7 +107,7 @@ class CashuProcessor {
|
||||
return GenericLoadable.Error("Could not parse this cashu token")
|
||||
}
|
||||
|
||||
fun parseCashuA(cashuToken: String): GenericLoadable<List<CashuToken>> {
|
||||
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
@@ -129,10 +131,10 @@ class CashuProcessor {
|
||||
CashuToken(cashuToken, mint, totalAmount, proofs)
|
||||
}
|
||||
|
||||
return GenericLoadable.Loaded(converted)
|
||||
return GenericLoadable.Loaded(converted.toImmutableList())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
return GenericLoadable.Error<List<CashuToken>>("Could not parse this cashu token")
|
||||
return GenericLoadable.Error("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +174,7 @@ class CashuProcessor {
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun parseCashuB(cashuToken: String): GenericLoadable<List<CashuToken>> {
|
||||
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
@@ -205,7 +207,7 @@ class CashuProcessor {
|
||||
CashuToken(cashuToken, mint, totalAmount, proofs)
|
||||
}
|
||||
|
||||
return GenericLoadable.Loaded(converted)
|
||||
return GenericLoadable.Loaded(converted.toImmutableList())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
if (e is CancellationException) throw e
|
||||
@@ -216,6 +218,7 @@ class CashuProcessor {
|
||||
suspend fun melt(
|
||||
token: CashuToken,
|
||||
lud16: String,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String, String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
@@ -229,10 +232,12 @@ class CashuProcessor {
|
||||
// Make invoice and leave room for fees
|
||||
milliSats = token.totalAmount * 1000,
|
||||
message = "Calculate Fees for Cashu",
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { baseInvoice ->
|
||||
feeCalculator(
|
||||
token.mint,
|
||||
baseInvoice,
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { fees ->
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
@@ -240,8 +245,9 @@ class CashuProcessor {
|
||||
// Make invoice and leave room for fees
|
||||
milliSats = (token.totalAmount - fees) * 1000,
|
||||
message = "Redeem Cashu",
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, fees, onSuccess, onError, context)
|
||||
meltInvoice(token, invoice, fees, forceProxy, onSuccess, onError, context)
|
||||
},
|
||||
onProgress = {},
|
||||
onError = onError,
|
||||
@@ -262,6 +268,7 @@ class CashuProcessor {
|
||||
fun feeCalculator(
|
||||
mintAddress: String,
|
||||
invoice: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: (Int) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
@@ -269,8 +276,8 @@ class CashuProcessor {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
@@ -327,13 +334,14 @@ class CashuProcessor {
|
||||
token: CashuToken,
|
||||
invoice: String,
|
||||
fees: Int,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: (String, String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
|
||||
@@ -48,21 +48,22 @@ class FileHeader(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
forceProxy: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
|
||||
} else {
|
||||
onError(null)
|
||||
onError("Unable to download image from $fileUrl")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError(e.message)
|
||||
onError(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@ class FileHeader(
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val hash = CryptoUtils.sha256(data).toHexKey()
|
||||
@@ -178,7 +179,7 @@ class FileHeader(
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError(e.message)
|
||||
onError(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class Nip05NostrAddressVerifier {
|
||||
|
||||
suspend fun fetchNip05Json(
|
||||
nip05: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
@@ -65,7 +66,7 @@ class Nip05NostrAddressVerifier {
|
||||
.build()
|
||||
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
|
||||
HttpClientManager
|
||||
.getHttpClient()
|
||||
.getHttpClient(forceProxy(url))
|
||||
.newBuilder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
@@ -90,6 +91,7 @@ class Nip05NostrAddressVerifier {
|
||||
|
||||
suspend fun verifyNip05(
|
||||
nip05: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
@@ -100,6 +102,7 @@ class Nip05NostrAddressVerifier {
|
||||
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
forceProxy,
|
||||
onSuccess = {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -126,7 +129,7 @@ class Nip05NostrAddressVerifier {
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
if (hexKey == null) {
|
||||
onError("Username not found in the NIP05 JSON")
|
||||
onError("Username not found in the NIP05 JSON [$nip05]")
|
||||
} else {
|
||||
onSuccess(hexKey)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ object Nip11CachedRetriever {
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
@@ -74,23 +75,24 @@ object Nip11CachedRetriever {
|
||||
if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) {
|
||||
// just wait.
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, onInfo, onError)
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
} else if (doc is RetrieveResultError) {
|
||||
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
|
||||
onError(dirtyUrl, doc.error, null)
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, onInfo, onError)
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, onInfo, onError)
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun retrieve(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
@@ -98,6 +100,7 @@ object Nip11CachedRetriever {
|
||||
retriever.loadRelayInfo(
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
forceProxy = forceProxy,
|
||||
onInfo = {
|
||||
checkNotInMainThread()
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
@@ -123,6 +126,7 @@ class Nip11Retriever {
|
||||
suspend fun loadRelayInfo(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
@@ -136,7 +140,7 @@ class Nip11Retriever {
|
||||
.build()
|
||||
|
||||
HttpClientManager
|
||||
.getHttpClientForUrl(dirtyUrl)
|
||||
.getHttpClient(forceProxy)
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
|
||||
@@ -27,6 +27,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Request
|
||||
import java.net.URI
|
||||
import java.net.URL
|
||||
|
||||
object Nip96MediaServers {
|
||||
val DEFAULT =
|
||||
@@ -45,11 +47,14 @@ object Nip96MediaServers {
|
||||
|
||||
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
||||
|
||||
suspend fun load(url: String): Nip96Retriever.ServerInfo {
|
||||
suspend fun load(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
): Nip96Retriever.ServerInfo {
|
||||
val cached = cache[url]
|
||||
if (cached != null) return cached
|
||||
|
||||
val fetched = Nip96Retriever().loadInfo(url)
|
||||
val fetched = Nip96Retriever().loadInfo(url, forceProxy)
|
||||
cache[url] = fetched
|
||||
return fetched
|
||||
}
|
||||
@@ -76,13 +81,32 @@ class Nip96Retriever {
|
||||
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
|
||||
)
|
||||
|
||||
fun parse(body: String): ServerInfo {
|
||||
fun parse(
|
||||
baseUrl: String,
|
||||
body: String,
|
||||
): ServerInfo {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, ServerInfo::class.java)
|
||||
val serverInfo = mapper.readValue(body, ServerInfo::class.java)
|
||||
|
||||
return serverInfo.copy(
|
||||
apiUrl = makeAbsoluteIfRelativeUrl(baseUrl, serverInfo.apiUrl),
|
||||
downloadUrl = serverInfo.downloadUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
|
||||
delegatedToUrl = serverInfo.delegatedToUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
|
||||
tosUrl = serverInfo.tosUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
|
||||
plans =
|
||||
serverInfo.plans.mapValues { u ->
|
||||
u.value.copy(
|
||||
url = u.value.url?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadInfo(baseUrl: String): ServerInfo {
|
||||
suspend fun loadInfo(
|
||||
baseUrl: String,
|
||||
forceProxy: Boolean,
|
||||
): ServerInfo {
|
||||
checkNotInMainThread()
|
||||
|
||||
val request: Request =
|
||||
@@ -92,13 +116,13 @@ class Nip96Retriever {
|
||||
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
|
||||
.build()
|
||||
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use { response ->
|
||||
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { response ->
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
return parse(body)
|
||||
return parse(baseUrl, body)
|
||||
} else {
|
||||
throw RuntimeException(
|
||||
"Resulting Message from $baseUrl is an error: ${response.code} ${response.message}",
|
||||
@@ -117,3 +141,18 @@ class Nip96Retriever {
|
||||
typealias PlanName = String
|
||||
|
||||
typealias MimeType = String
|
||||
|
||||
fun makeAbsoluteIfRelativeUrl(
|
||||
baseUrl: String,
|
||||
potentialyRelativeUrl: String,
|
||||
): String =
|
||||
try {
|
||||
val apiUrl = URI(potentialyRelativeUrl)
|
||||
if (apiUrl.isAbsolute) {
|
||||
potentialyRelativeUrl
|
||||
} else {
|
||||
URL(URL(baseUrl), potentialyRelativeUrl).toString()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
potentialyRelativeUrl
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ class Nip96Uploader(
|
||||
sensitiveContent: String?,
|
||||
server: Nip96MediaServers.ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
@@ -69,6 +70,7 @@ class Nip96Uploader(
|
||||
Nip96Retriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
forceProxy(server.baseUrl),
|
||||
)
|
||||
|
||||
return uploadImage(
|
||||
@@ -79,6 +81,7 @@ class Nip96Uploader(
|
||||
sensitiveContent,
|
||||
serverInfo,
|
||||
contentResolver,
|
||||
forceProxy,
|
||||
onProgress,
|
||||
context,
|
||||
)
|
||||
@@ -92,6 +95,7 @@ class Nip96Uploader(
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
contentResolver: ContentResolver,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
@@ -118,6 +122,7 @@ class Nip96Uploader(
|
||||
alt,
|
||||
sensitiveContent,
|
||||
server,
|
||||
forceProxy,
|
||||
onProgress,
|
||||
context,
|
||||
)
|
||||
@@ -130,6 +135,7 @@ class Nip96Uploader(
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
@@ -139,7 +145,7 @@ class Nip96Uploader(
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
val requestBody: RequestBody =
|
||||
@@ -149,7 +155,7 @@ class Nip96Uploader(
|
||||
.addFormDataPart("expiration", "")
|
||||
.addFormDataPart("size", length.toString())
|
||||
.also { body ->
|
||||
alt?.let { body.addFormDataPart("alt", it) }
|
||||
alt?.ifBlank { null }?.let { body.addFormDataPart("alt", it) }
|
||||
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
|
||||
contentType?.let { body.addFormDataPart("content_type", it) }
|
||||
}.addFormDataPart(
|
||||
@@ -182,7 +188,7 @@ class Nip96Uploader(
|
||||
val result = parseResults(str)
|
||||
|
||||
if (!result.processingUrl.isNullOrBlank()) {
|
||||
return waitProcessing(result, server, onProgress)
|
||||
return waitProcessing(result, server, forceProxy, onProgress)
|
||||
} else if (result.status == "success" && result.nip94Event != null) {
|
||||
return result.nip94Event
|
||||
} else {
|
||||
@@ -190,8 +196,26 @@ class Nip96Uploader(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val msg = response.body.string()
|
||||
|
||||
val errorMessage =
|
||||
try {
|
||||
val tree = jacksonObjectMapper().readTree(msg)
|
||||
val status = tree.get("status")?.asText()
|
||||
val message = tree.get("message")?.asText()
|
||||
if (status == "error" && message != null) {
|
||||
message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
if (explanation != null) {
|
||||
if (errorMessage != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
|
||||
} else if (explanation != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
|
||||
@@ -204,12 +228,13 @@ class Nip96Uploader(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
context: Context,
|
||||
): Boolean {
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
@@ -243,9 +268,9 @@ class Nip96Uploader(
|
||||
private suspend fun waitProcessing(
|
||||
result: Nip96Result,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
): PartialEvent {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
var currentResult = result
|
||||
|
||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||
@@ -258,6 +283,7 @@ class Nip96Uploader(
|
||||
.url(result.processingUrl)
|
||||
.build()
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(result.processingUrl))
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
it.body.use { currentResult = parseResults(it.string()) }
|
||||
|
||||
@@ -68,6 +68,7 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
// TODO: Migrate this to a property of AccountVi
|
||||
object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
@@ -163,7 +164,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultNotificationFollowList.value)
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -179,12 +180,26 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
)
|
||||
|
||||
fun createNotificationFilter(): TypedFilter {
|
||||
val since =
|
||||
var since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultNotificationFollowList.value)
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList
|
||||
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?.toMutableMap()
|
||||
|
||||
if (since == null) {
|
||||
since =
|
||||
account.connectToRelays.value
|
||||
.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
.toMutableMap()
|
||||
} else {
|
||||
account.connectToRelays.value.forEach {
|
||||
val eose = since.get(it.url)
|
||||
if (eose == null) {
|
||||
since.put(it.url, EOSETime(TimeUtils.oneWeekAgo()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
@@ -214,7 +229,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
val since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultNotificationFollowList.value)
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList
|
||||
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
@@ -263,7 +278,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.defaultNotificationFollowList.value,
|
||||
account.settings.defaultNotificationFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
@@ -284,99 +299,121 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
override fun consume(
|
||||
event: Event,
|
||||
relay: Relay,
|
||||
) {
|
||||
if (LocalCache.justVerify(event)) {
|
||||
consumeAlreadyVerified(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeAlreadyVerified(
|
||||
event: Event,
|
||||
relay: Relay,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (LocalCache.justVerify(event)) {
|
||||
when (event) {
|
||||
is PrivateOutboxRelayListEvent -> {
|
||||
when (event) {
|
||||
is PrivateOutboxRelayListEvent -> {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
|
||||
event.privateTags(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DraftEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
|
||||
if (!event.isDeleted()) {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
|
||||
event.privateTags(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DraftEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
|
||||
if (!event.isDeleted()) {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent != null) {
|
||||
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
} else {
|
||||
// decrypts
|
||||
event.cachedDraft(account.signer) {}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? GiftWrapEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
noteEvent.cachedGift(account.signer) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
this.consume(it, relay)
|
||||
}
|
||||
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.cachedGift(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SealedGossipEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? SealedGossipEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
// adds the relay to seal and inner chat
|
||||
noteEvent.cachedGossip(account.signer) {
|
||||
LocalCache.consume(noteEvent, relay)
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.cachedGossip(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note?.event == null) {
|
||||
event.zapRequest?.let {
|
||||
if (it.isPrivateZap()) {
|
||||
it.decryptPrivateZap(account.signer) {}
|
||||
}
|
||||
}
|
||||
// decrypts
|
||||
event.cachedDraft(account.signer) {}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? GiftWrapEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
|
||||
noteEvent.innerEventId?.let {
|
||||
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
|
||||
this.consumeAlreadyVerified(it, relay)
|
||||
}
|
||||
} ?: run {
|
||||
event.unwrap(account.signer) {
|
||||
this.consume(it, relay)
|
||||
noteEvent.innerEventId = it.id
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.unwrap(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SealedGossipEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? SealedGossipEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
|
||||
noteEvent.innerEventId?.let {
|
||||
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
} ?: run {
|
||||
event.unseal(account.signer) {
|
||||
LocalCache.justConsume(it, relay)
|
||||
noteEvent.innerEventId = it.id
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.unseal(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note?.event == null) {
|
||||
event.zapRequest?.let {
|
||||
if (it.isPrivateZap()) {
|
||||
it.decryptPrivateZap(account.signer) {}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,16 +431,30 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
}
|
||||
|
||||
private fun markInnerAsSeenOnRelay(
|
||||
noteEvent: EventInterface,
|
||||
newNoteEvent: EventInterface,
|
||||
relay: Relay,
|
||||
) {
|
||||
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
|
||||
markInnerAsSeenOnRelay(newNoteEvent.id(), relay)
|
||||
}
|
||||
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, relay) }
|
||||
} else if (noteEvent is SealedGossipEvent) {
|
||||
noteEvent.cachedGossip(account.signer) { rumor ->
|
||||
markInnerAsSeenOnRelay(rumor, relay)
|
||||
private fun markInnerAsSeenOnRelay(
|
||||
eventId: HexKey,
|
||||
relay: Relay,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(eventId)
|
||||
|
||||
if (note != null) {
|
||||
note.addRelay(relay)
|
||||
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
noteEvent.innerEventId?.let {
|
||||
markInnerAsSeenOnRelay(it, relay)
|
||||
}
|
||||
} else if (noteEvent is SealedGossipEvent) {
|
||||
noteEvent.innerEventId?.let {
|
||||
markInnerAsSeenOnRelay(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,10 +494,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
account.createAuthEvent(relay, challenge) {
|
||||
Client.send(
|
||||
it,
|
||||
relay.url,
|
||||
)
|
||||
Client.sendIfExists(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,15 +35,19 @@ object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed")
|
||||
|
||||
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
|
||||
|
||||
val authors =
|
||||
community
|
||||
.moderators()
|
||||
.map { it.key }
|
||||
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex))
|
||||
|
||||
if (authors.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
authors =
|
||||
community
|
||||
.moderators()
|
||||
.map { it.key }
|
||||
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
|
||||
authors = authors,
|
||||
tags =
|
||||
mapOf(
|
||||
"a" to listOf(myCommunityToWatch.address.toTag()),
|
||||
|
||||
@@ -67,15 +67,17 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
}
|
||||
|
||||
fun createMarketplaceFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryListAuthorsPerRelay.value
|
||||
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
|
||||
val hashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.hashtags
|
||||
?.toList()
|
||||
?.ifEmpty { null }
|
||||
val geohashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.geotags
|
||||
?.toList()
|
||||
?.ifEmpty { null }
|
||||
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
@@ -88,7 +90,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
@@ -109,7 +111,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -131,7 +133,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -151,7 +153,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
@@ -162,12 +164,13 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.users
|
||||
?.toList()
|
||||
?.ifEmpty { null }
|
||||
|
||||
val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value
|
||||
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = followsRelays,
|
||||
@@ -176,13 +179,13 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
follows?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags = mapOf("p" to it),
|
||||
@@ -191,7 +194,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -200,7 +203,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
}
|
||||
|
||||
fun createPublicChatFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryListAuthorsPerRelay.value
|
||||
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
|
||||
val followChats = account.selectedChatsFollowList().toList()
|
||||
|
||||
return listOfNotNull(
|
||||
@@ -214,7 +217,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
@@ -229,7 +232,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -243,7 +246,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
val follows = account.liveDiscoveryListAuthorsPerRelay.value
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = follows,
|
||||
@@ -252,7 +255,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -282,7 +285,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -312,7 +315,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -343,7 +346,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -374,7 +377,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -404,7 +407,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -434,7 +437,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -444,7 +447,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
account.settings.defaultDiscoveryFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
|
||||
@@ -46,7 +46,6 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
@@ -55,25 +54,35 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
var job: Job? = null
|
||||
var job2: Job? = null
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// creates cache on main
|
||||
withContext(Dispatchers.Main) { account.userProfile().live() }
|
||||
account.liveHomeFollowLists.collect {
|
||||
if (this@NostrHomeDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
job2?.cancel()
|
||||
job2 =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.liveHomeListAuthorsPerRelay.collect {
|
||||
if (this@NostrHomeDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
job?.cancel()
|
||||
job2?.cancel()
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
@@ -105,7 +114,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -114,7 +123,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
fun createFollowMetadataAndReleaseFilter(): TypedFilter? {
|
||||
val follows = account.liveHomeListAuthorsPerRelay.value
|
||||
|
||||
return if (follows != null) {
|
||||
return if (!follows.isNullOrEmpty()) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
@@ -128,7 +137,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -170,7 +179,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -209,7 +218,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -244,7 +253,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -254,7 +263,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.defaultHomeFollowList.value,
|
||||
account.settings.defaultHomeFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
|
||||
object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
|
||||
private var nextEventsToWatch = setOf<Note>()
|
||||
@@ -179,6 +180,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
|
||||
DeletionEvent.KIND,
|
||||
NIP90ContentDiscoveryResponseEvent.KIND,
|
||||
NIP90StatusEvent.KIND,
|
||||
TorrentCommentEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
|
||||
@@ -65,26 +65,30 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
|
||||
val groupIds = group.map { it.pubkeyHex }
|
||||
val minEOSEs = findMinimumEOSEsForUsers(group)
|
||||
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
|
||||
authors = groupIds,
|
||||
since = minEOSEs,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ReportEvent.KIND),
|
||||
tags = mapOf("p" to groupIds),
|
||||
since = minEOSEs,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (groupIds.isNotEmpty()) {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
|
||||
authors = groupIds,
|
||||
since = minEOSEs,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ReportEvent.KIND),
|
||||
tags = mapOf("p" to groupIds),
|
||||
since = minEOSEs,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
|
||||
@@ -89,6 +91,23 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserPostsFilter2() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TorrentEvent.KIND,
|
||||
TorrentCommentEvent.KIND,
|
||||
),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 20,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReceivedZapsFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
@@ -188,6 +207,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
|
||||
listOfNotNull(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createUserPostsFilter2(),
|
||||
createProfileGalleryFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter(),
|
||||
|
||||
@@ -78,7 +78,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultStoriesFollowList.value)
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -109,7 +109,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultStoriesFollowList.value)
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -140,7 +140,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultStoriesFollowList.value)
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -150,7 +150,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.defaultStoriesFollowList.value,
|
||||
account.settings.defaultStoriesFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
|
||||
@@ -49,7 +49,10 @@ object OnlineChecker {
|
||||
return false
|
||||
}
|
||||
|
||||
fun isOnline(url: String?): Boolean {
|
||||
fun isOnline(
|
||||
url: String?,
|
||||
forceProxy: Boolean,
|
||||
): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (url.isNullOrBlank()) return false
|
||||
@@ -74,7 +77,7 @@ object OnlineChecker {
|
||||
|
||||
val client =
|
||||
HttpClientManager
|
||||
.getHttpClient()
|
||||
.getHttpClient(forceProxy)
|
||||
.newBuilder()
|
||||
.eventListener(EventListener.NONE)
|
||||
.protocols(listOf(Protocol.HTTP_1_1))
|
||||
@@ -93,7 +96,7 @@ object OnlineChecker {
|
||||
.get()
|
||||
.build()
|
||||
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -60,6 +61,7 @@ class ZapPaymentHandler(
|
||||
message: String,
|
||||
context: Context,
|
||||
showErrorIfNoLnAddress: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
@@ -81,13 +83,15 @@ class ZapPaymentHandler(
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
if (showErrorIfNoLnAddress) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
@@ -97,13 +101,15 @@ class ZapPaymentHandler(
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
if (showErrorIfNoLnAddress) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
@@ -119,7 +125,7 @@ class ZapPaymentHandler(
|
||||
onProgress(0.05f)
|
||||
}
|
||||
|
||||
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, onError, onProgress = {
|
||||
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = {
|
||||
onProgress(it * 0.7f + 0.05f) // keeps within range.
|
||||
}, context) {
|
||||
if (it.isEmpty()) {
|
||||
@@ -165,7 +171,7 @@ class ZapPaymentHandler(
|
||||
}
|
||||
|
||||
class SignAllZapRequestsReturn(
|
||||
val zapRequestJson: String,
|
||||
val zapRequestJson: String?,
|
||||
val user: User? = null,
|
||||
)
|
||||
|
||||
@@ -211,9 +217,7 @@ class ZapPaymentHandler(
|
||||
) + (authorRelayList ?: emptySet())
|
||||
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
|
||||
if (zapRequestJson != null) {
|
||||
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
|
||||
}
|
||||
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -226,6 +230,7 @@ class ZapPaymentHandler(
|
||||
totalAmountMilliSats: Long,
|
||||
message: String,
|
||||
showErrorIfNoLnAddress: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
@@ -243,6 +248,7 @@ class ZapPaymentHandler(
|
||||
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
|
||||
message = message,
|
||||
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
|
||||
forceProxy = forceProxy,
|
||||
onError = onError,
|
||||
onProgressStep = { percentStepForThisPayment ->
|
||||
progressAllPayments += percentStepForThisPayment / invoices.size
|
||||
@@ -308,10 +314,11 @@ class ZapPaymentHandler(
|
||||
|
||||
private fun assembleInvoice(
|
||||
splitSetup: ZapSplitSetup,
|
||||
nostrZapRequest: String,
|
||||
nostrZapRequest: String?,
|
||||
zapValue: Long,
|
||||
message: String,
|
||||
showErrorIfNoLnAddress: Boolean = true,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgressStep: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
@@ -335,6 +342,7 @@ class ZapPaymentHandler(
|
||||
milliSats = zapValue,
|
||||
message = message,
|
||||
nostrRequest = nostrZapRequest,
|
||||
forceProxy = forceProxy,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
val step = it - progressThisPayment
|
||||
|
||||
@@ -36,8 +36,6 @@ import java.net.URLEncoder
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class LightningAddressResolver {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
|
||||
@@ -54,6 +52,7 @@ class LightningAddressResolver {
|
||||
|
||||
private fun fetchLightningAddressJson(
|
||||
lnaddress: String,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
@@ -74,6 +73,8 @@ class LightningAddressResolver {
|
||||
return
|
||||
}
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
try {
|
||||
val request: Request =
|
||||
Request
|
||||
@@ -121,6 +122,7 @@ class LightningAddressResolver {
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
@@ -137,6 +139,8 @@ class LightningAddressResolver {
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
@@ -161,6 +165,7 @@ class LightningAddressResolver {
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
@@ -170,6 +175,7 @@ class LightningAddressResolver {
|
||||
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
forceProxy,
|
||||
onSuccess = { lnAddressJson ->
|
||||
onProgress(0.4f)
|
||||
|
||||
@@ -210,6 +216,7 @@ class LightningAddressResolver {
|
||||
milliSats,
|
||||
message,
|
||||
if (allowsNostr) nostrRequest else null,
|
||||
forceProxy,
|
||||
onSuccess = {
|
||||
onProgress(0.6f)
|
||||
|
||||
|
||||
@@ -24,32 +24,39 @@ import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
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.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import java.math.BigDecimal
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class EventNotificationConsumer(
|
||||
private val applicationContext: Context,
|
||||
) {
|
||||
companion object {
|
||||
const val TAG = "EventNotificationConsumer"
|
||||
}
|
||||
|
||||
suspend fun consume(event: GiftWrapEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Notification Arrived")
|
||||
Log.d(TAG, "New Notification Arrived")
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (!notificationManager().areNotificationsEnabled()) return
|
||||
|
||||
@@ -57,14 +64,15 @@ class EventNotificationConsumer(
|
||||
// Test with all logged in accounts
|
||||
var matchAccount = false
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
if (!matchAccount && it.hasPrivKey || it.loggedInWithExternalSigner) {
|
||||
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) {
|
||||
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
|
||||
Log.d("EventNotificationConsumer", "New Notification Testing if for ${it.npub}")
|
||||
Log.d(TAG, "New Notification Testing if for ${it.npub}")
|
||||
try {
|
||||
consumeIfMatchesAccount(event, acc)
|
||||
matchAccount = true
|
||||
} catch (e: Exception) {
|
||||
// Not for this account.
|
||||
if (e is CancellationException) throw e
|
||||
Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,28 +81,35 @@ class EventNotificationConsumer(
|
||||
|
||||
private suspend fun consumeIfMatchesAccount(
|
||||
pushWrappedEvent: GiftWrapEvent,
|
||||
account: Account,
|
||||
account: AccountSettings,
|
||||
) {
|
||||
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
|
||||
// TODO: Modify the external launcher to launch as different users.
|
||||
// Right now it only registers if Amber has already approved this signature
|
||||
val signer = account.createSigner()
|
||||
if (signer is NostrSignerExternal) {
|
||||
signer.launcher.registerLauncher(
|
||||
launcher = { },
|
||||
contentResolver = Amethyst.instance::contentResolverFn,
|
||||
)
|
||||
}
|
||||
|
||||
pushWrappedEvent.unwrapThrowing(signer) { notificationEvent ->
|
||||
val consumed = LocalCache.hasConsumed(notificationEvent)
|
||||
val verified = LocalCache.justVerify(notificationEvent)
|
||||
Log.d("EventNotificationConsumer", "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified")
|
||||
Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified")
|
||||
if (!consumed && verified) {
|
||||
Log.d("EventNotificationConsumer", "New Notification was verified")
|
||||
unwrapAndConsume(notificationEvent, account) { innerEvent ->
|
||||
|
||||
Log.d("EventNotificationConsumer", "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
|
||||
if (!consumed) {
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Nip-04 DM to Notify")
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Zap to Notify")
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
Log.d(TAG, "New Notification was verified")
|
||||
unwrapAndConsume(notificationEvent, signer) { innerEvent ->
|
||||
Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
Log.d(TAG, "New Nip-04 DM to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
Log.d(TAG, "New Zap to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
Log.d(TAG, "New ChatMessage to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +118,7 @@ class EventNotificationConsumer(
|
||||
|
||||
private fun unwrapAndConsume(
|
||||
event: Event,
|
||||
account: Account,
|
||||
signer: NostrSigner,
|
||||
onReady: (Event) -> Unit,
|
||||
) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
@@ -111,15 +126,19 @@ class EventNotificationConsumer(
|
||||
|
||||
when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) }
|
||||
event.unwrap(signer) {
|
||||
unwrapAndConsume(it, signer, onReady)
|
||||
LocalCache.justConsume(event, null)
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
event.cachedGossip(account.signer) {
|
||||
event.unseal(signer) {
|
||||
if (!LocalCache.hasConsumed(it)) {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
}
|
||||
LocalCache.justConsume(event, null)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -131,25 +150,26 @@ class EventNotificationConsumer(
|
||||
|
||||
private fun notify(
|
||||
event: ChatMessageEvent,
|
||||
acc: Account,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
if (
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
// old event being re-broadcasted
|
||||
event.pubKey != acc.userProfile().pubkeyHex
|
||||
event.pubKey != signer.pubKey
|
||||
) { // from the user
|
||||
|
||||
Log.d(TAG, "Notifying")
|
||||
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
|
||||
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
val chatRoom = event.chatroomKey(signer.pubKey)
|
||||
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
|
||||
|
||||
val isKnownRoom =
|
||||
(
|
||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||
) &&
|
||||
!acc.isAllHidden(chatRoom.users)
|
||||
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
myUser.hasSentMessagesTo(chatRoom)
|
||||
)
|
||||
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
@@ -172,32 +192,27 @@ class EventNotificationConsumer(
|
||||
|
||||
private fun notify(
|
||||
event: PrivateDmEvent,
|
||||
acc: Account,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
if (signer.pubKey == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
|
||||
|
||||
val knownChatrooms =
|
||||
acc
|
||||
.userProfile()
|
||||
.privateChatrooms
|
||||
.keys
|
||||
.filter {
|
||||
(
|
||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(it)
|
||||
) &&
|
||||
!acc.isAllHidden(it.users)
|
||||
}.toSet()
|
||||
val chatRoom = event.chatroomKey(signer.pubKey)
|
||||
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
acc.decryptContent(note) { content ->
|
||||
val isKnownRoom =
|
||||
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
myUser.hasSentMessagesTo(chatRoom)
|
||||
|
||||
if (isKnownRoom) {
|
||||
note.author?.let {
|
||||
decryptContent(note, signer) { content ->
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
@@ -209,45 +224,80 @@ class EventNotificationConsumer(
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapContentAuthor(
|
||||
note: Note,
|
||||
signer: NostrSigner,
|
||||
onReady: (Event) -> Unit,
|
||||
) {
|
||||
val event = note.event
|
||||
if (event is LnZapRequestEvent) {
|
||||
if (event.isPrivateZap()) {
|
||||
event.decryptPrivateZap(signer) { onReady(it) }
|
||||
} else {
|
||||
onReady(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptContent(
|
||||
note: Note,
|
||||
signer: NostrSigner,
|
||||
onReady: (String) -> Unit,
|
||||
) {
|
||||
val event = note.event
|
||||
if (event is PrivateDmEvent) {
|
||||
event.plainContent(signer, onReady)
|
||||
} else if (event is LnZapRequestEvent) {
|
||||
decryptZapContentAuthor(note, signer) { onReady(it.content) }
|
||||
} else if (event is DraftEvent) {
|
||||
event.cachedDraft(signer) {
|
||||
onReady(it.content)
|
||||
}
|
||||
} else {
|
||||
event?.content()?.let { onReady(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
event: LnZapEvent,
|
||||
acc: Account,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
Log.d("EventNotificationConsumer", "Notify Start ${event.toNostrUri()}")
|
||||
Log.d(TAG, "Notify Start ${event.toNostrUri()}")
|
||||
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify Not Notified Yet")
|
||||
Log.d(TAG, "Notify Not Notified Yet")
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify Not an old event")
|
||||
Log.d(TAG, "Notify Not an old event")
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
val noteZapped =
|
||||
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify ZapRequest $noteZapRequest zapped $noteZapped")
|
||||
Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped")
|
||||
|
||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify Amount Bigger than 10")
|
||||
Log.d(TAG, "Notify Amount Bigger than 10")
|
||||
|
||||
if (event.isTaggedUser(acc.userProfile().pubkeyHex)) {
|
||||
if (event.isTaggedUser(signer.pubKey)) {
|
||||
val amount = showAmount(event.amount)
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify Amount $amount")
|
||||
Log.d(TAG, "Notify Amount $amount")
|
||||
|
||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||
acc.decryptZapContentAuthor(noteZapRequest) {
|
||||
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Zap ${event.id}")
|
||||
decryptZapContentAuthor(noteZapRequest, signer) {
|
||||
Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}")
|
||||
|
||||
val author = LocalCache.getOrCreateUser(it.pubKey)
|
||||
val senderInfo = Pair(author, it.content.ifBlank { null })
|
||||
|
||||
if (noteZapped.event?.content() != null) {
|
||||
acc.decryptContent(noteZapped) {
|
||||
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note")
|
||||
decryptContent(noteZapped, signer) {
|
||||
Log.d(TAG, "Notify Decrypted if Private Note")
|
||||
|
||||
val zappedContent = it.split("\n").get(0)
|
||||
|
||||
@@ -270,10 +320,10 @@ class EventNotificationConsumer(
|
||||
zappedContent,
|
||||
)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
|
||||
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
@@ -288,7 +338,7 @@ class EventNotificationConsumer(
|
||||
}
|
||||
} else {
|
||||
// doesn't have a base note to refer to.
|
||||
Log.d("EventNotificationConsumer", "Notify Zapped note not available")
|
||||
Log.d(TAG, "Notify Zapped note not available")
|
||||
|
||||
val user = senderInfo.first.toBestDisplayName()
|
||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||
@@ -304,7 +354,7 @@ class EventNotificationConsumer(
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
|
||||
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
|
||||
@@ -22,14 +22,13 @@ package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -55,7 +54,7 @@ class RegisterAccounts(
|
||||
|
||||
private suspend fun signAllAuths(
|
||||
notificationToken: String,
|
||||
remainingTos: List<Pair<Account, List<String>>>,
|
||||
remainingTos: List<Pair<AccountSettings, List<String>>>,
|
||||
output: MutableList<RelayAuthEvent>,
|
||||
onReady: (List<RelayAuthEvent>) -> Unit,
|
||||
) {
|
||||
@@ -71,7 +70,17 @@ class RegisterAccounts(
|
||||
val result =
|
||||
withTimeoutOrNull(10000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
accountRelayPair.first.createAuthEvent(accountRelayPair.second, notificationToken) { result ->
|
||||
val signer = accountRelayPair.first.createSigner()
|
||||
// TODO: Modify the external launcher to launch as different users.
|
||||
// Right now it only registers if Amber has already approved this signature
|
||||
if (signer is NostrSignerExternal) {
|
||||
signer.launcher.registerLauncher(
|
||||
launcher = { },
|
||||
contentResolver = Amethyst.instance::contentResolverFn,
|
||||
)
|
||||
}
|
||||
|
||||
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
@@ -104,32 +113,18 @@ class RegisterAccounts(
|
||||
Log.d(tag, "Register Account ${it.npub}")
|
||||
|
||||
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
|
||||
|
||||
if (acc != null && acc.isWriteable()) {
|
||||
val nip65Read =
|
||||
(
|
||||
LocalCache
|
||||
.getAddressableNoteIfExists(
|
||||
AdvertisedRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex),
|
||||
)?.event as? AdvertisedRelayListEvent
|
||||
)?.readRelays() ?: acc.backupNIP65RelayList?.readRelays() ?: emptyList<String>()
|
||||
val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList()
|
||||
|
||||
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP65 Reads ${nip65Read.joinToString(", ")}")
|
||||
Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}")
|
||||
|
||||
val nip17Read =
|
||||
(
|
||||
LocalCache
|
||||
.getAddressableNoteIfExists(
|
||||
ChatMessageRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex),
|
||||
)?.event as? ChatMessageRelayListEvent
|
||||
)?.relays() ?: acc.backupDMRelayList?.relays() ?: emptyList<String>()
|
||||
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList<String>()
|
||||
|
||||
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP17 Reads ${nip17Read.joinToString(", ")}")
|
||||
Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}")
|
||||
|
||||
val kind3Relays = (acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays())
|
||||
val readKind3Relays = kind3Relays?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
|
||||
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
|
||||
|
||||
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
|
||||
Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
|
||||
|
||||
val relays = (nip65Read + nip17Read + readKind3Relays)
|
||||
|
||||
@@ -171,19 +166,24 @@ class RegisterAccounts(
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
// Always try via Tor for Amethyst.
|
||||
val client = HttpClientManager.getHttpClient(true)
|
||||
|
||||
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
|
||||
Log.i(tag, "Server registration $isSucess")
|
||||
} catch (e: java.lang.Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(tag, "Unable to register with push server", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun go(notificationToken: String) =
|
||||
withContext(Dispatchers.IO) {
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
|
||||
suspend fun go(notificationToken: String) {
|
||||
if (notificationToken.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
|
||||
|
||||
PushNotificationUtils.hasInit = true
|
||||
PushNotificationUtils.hasInit = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.ots
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
@@ -29,7 +30,12 @@ import com.vitorpamplona.quartz.ots.BlockHeader
|
||||
import com.vitorpamplona.quartz.ots.exceptions.UrlException
|
||||
import okhttp3.Request
|
||||
|
||||
class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
class OkHttpBlockstreamExplorer(
|
||||
val forceProxy: (String) -> Boolean,
|
||||
) : BitcoinExplorer {
|
||||
private val cacheHeaders = LruCache<String, BlockHeader>(100)
|
||||
private val cacheHeights = LruCache<Int, String>(100)
|
||||
|
||||
/**
|
||||
* Retrieve the block information from the block hash.
|
||||
*
|
||||
@@ -38,8 +44,13 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
* @throws Exception desc
|
||||
*/
|
||||
override fun block(hash: String): BlockHeader {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$BLOCKSTREAM_API_URL/block/$hash"
|
||||
cacheHeaders.get(hash)?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val usingTor = forceProxy(BLOCKSTREAM_API_URL)
|
||||
val url = "${getAPI(usingTor)}/block/$hash"
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val request =
|
||||
Request
|
||||
@@ -59,6 +70,9 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
blockHeader.setTime(jsonObject["timestamp"].asInt().toString())
|
||||
blockHeader.blockHash = hash
|
||||
Log.d("OkHttpBlockstreamExplorer", "$BLOCKSTREAM_API_URL/block/$hash")
|
||||
|
||||
cacheHeaders.put(hash, blockHeader)
|
||||
|
||||
return blockHeader
|
||||
} else {
|
||||
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
|
||||
@@ -75,9 +89,13 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun blockHash(height: Int): String {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
cacheHeights[height]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val url = "$BLOCKSTREAM_API_URL/block-height/$height"
|
||||
val usingTor = forceProxy(BLOCKSTREAM_API_URL)
|
||||
val url = "${getAPI(usingTor)}/block-height/$height"
|
||||
val client = HttpClientManager.getHttpClient(usingTor)
|
||||
|
||||
val request =
|
||||
Request
|
||||
@@ -92,14 +110,24 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
val blockHash = it.body.string()
|
||||
|
||||
Log.d("OkHttpBlockstreamExplorer", "$url $blockHash")
|
||||
|
||||
cacheHeights.put(height, blockHash)
|
||||
return blockHash
|
||||
} else {
|
||||
throw UrlException(it.message)
|
||||
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BLOCKSTREAM_API_URL = "https://blockstream.info/api"
|
||||
private const val MEMPOOL_API_URL = "https://mempool.space/api/"
|
||||
|
||||
fun getAPI(usingTor: Boolean) =
|
||||
if (usingTor) {
|
||||
MEMPOOL_API_URL
|
||||
} else {
|
||||
BLOCKSTREAM_API_URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
*/
|
||||
class OkHttpCalendar(
|
||||
val url: String,
|
||||
val forceProxy: Boolean,
|
||||
) : ICalendar {
|
||||
/**
|
||||
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
|
||||
@@ -51,7 +52,7 @@ class OkHttpCalendar(
|
||||
@Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class)
|
||||
override fun submit(digest: ByteArray): Timestamp {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
val url = "$url/digest"
|
||||
|
||||
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
|
||||
@@ -102,7 +103,7 @@ class OkHttpCalendar(
|
||||
)
|
||||
override fun getTimestamp(commitment: ByteArray): Timestamp {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
val url = url + "/timestamp/" + Hex.encode(commitment)
|
||||
|
||||
val request =
|
||||
|
||||
@@ -36,6 +36,7 @@ import java.util.concurrent.BlockingQueue
|
||||
class OkHttpCalendarAsyncSubmit(
|
||||
private val url: String,
|
||||
private val digest: ByteArray,
|
||||
private val forceProxy: Boolean,
|
||||
) : ICalendarAsyncSubmit {
|
||||
private var queue: BlockingQueue<Optional<Timestamp>>? = null
|
||||
|
||||
@@ -45,7 +46,7 @@ class OkHttpCalendarAsyncSubmit(
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun call(): Optional<Timestamp> {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
val url = "$url/digest"
|
||||
|
||||
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
|
||||
|
||||
@@ -24,11 +24,13 @@ import com.vitorpamplona.quartz.ots.CalendarBuilder
|
||||
import com.vitorpamplona.quartz.ots.ICalendar
|
||||
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
|
||||
|
||||
class OkHttpCalendarBuilder : CalendarBuilder {
|
||||
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url)
|
||||
class OkHttpCalendarBuilder(
|
||||
val forceProxy: (String) -> Boolean,
|
||||
) : CalendarBuilder {
|
||||
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url, forceProxy(url))
|
||||
|
||||
override fun newAsyncCalendar(
|
||||
url: String,
|
||||
digest: ByteArray,
|
||||
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest)
|
||||
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest, forceProxy(url))
|
||||
}
|
||||
|
||||
@@ -28,17 +28,17 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* HLS LiveStreams cannot use cache.
|
||||
*/
|
||||
@UnstableApi
|
||||
class CustomMediaSourceFactory : MediaSource.Factory {
|
||||
private var cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
|
||||
private var nonCachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
|
||||
class CustomMediaSourceFactory(
|
||||
val okHttpClient: OkHttpClient,
|
||||
) : MediaSource.Factory {
|
||||
private var cachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(okHttpClient))
|
||||
private var nonCachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.PositionInfo
|
||||
import androidx.media3.common.Player.STATE_IDLE
|
||||
import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
@@ -40,7 +41,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
class MultiPlayerPlaybackManager(
|
||||
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
|
||||
@UnstableApi
|
||||
val dataSourceFactory: CustomMediaSourceFactory,
|
||||
private val cachedPositions: VideoViewedPositionCache,
|
||||
) {
|
||||
// protects from LruCache killing playing sessions
|
||||
|
||||
@@ -38,6 +38,7 @@ object PlaybackClientController {
|
||||
controllerID: String,
|
||||
videoUri: String,
|
||||
callbackUri: String?,
|
||||
proxyPort: Int? = 0,
|
||||
context: Context,
|
||||
onReady: (MediaController) -> Unit,
|
||||
) {
|
||||
@@ -48,6 +49,9 @@ object PlaybackClientController {
|
||||
bundle.putString("id", controllerID)
|
||||
bundle.putString("uri", videoUri)
|
||||
bundle.putString("callbackUri", callbackUri)
|
||||
proxyPort?.let {
|
||||
bundle.putInt("proxyPort", it)
|
||||
}
|
||||
|
||||
var session = cache.get(context.hashCode())
|
||||
if (session == null) {
|
||||
|
||||
@@ -30,18 +30,47 @@ import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var videoViewedPositionCache = VideoViewedPositionCache()
|
||||
|
||||
private var managerAllInOne: MultiPlayerPlaybackManager? = null
|
||||
private var managerAllInOneNoProxy: MultiPlayerPlaybackManager? = null
|
||||
private var managerAllInOneProxy: MultiPlayerPlaybackManager? = null
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun lazyDS(): MultiPlayerPlaybackManager {
|
||||
managerAllInOne?.let {
|
||||
return it
|
||||
}
|
||||
fun lazyDS(proxyPort: Int): MultiPlayerPlaybackManager {
|
||||
if (proxyPort <= 0) {
|
||||
// no proxy
|
||||
managerAllInOneNoProxy?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(), videoViewedPositionCache)
|
||||
managerAllInOne = newInstance
|
||||
return newInstance
|
||||
// creates new
|
||||
val okHttp = HttpClientManager.getHttpClient(false)
|
||||
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
|
||||
managerAllInOneNoProxy = newInstance
|
||||
return newInstance
|
||||
} else {
|
||||
// with proxy, check if the port is the same.
|
||||
managerAllInOneProxy?.let {
|
||||
val okHttp = HttpClientManager.getHttpClient(true)
|
||||
if (okHttp == it.dataSourceFactory.okHttpClient.proxy) {
|
||||
return it
|
||||
}
|
||||
|
||||
val toDestroyAllInOne = managerAllInOneProxy
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
|
||||
|
||||
managerAllInOneProxy = newInstance
|
||||
|
||||
toDestroyAllInOne?.releaseAppPlayers()
|
||||
|
||||
return newInstance
|
||||
}
|
||||
|
||||
// creates new
|
||||
val okHttp = HttpClientManager.getHttpClient(true)
|
||||
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
|
||||
managerAllInOneProxy = newInstance
|
||||
return newInstance
|
||||
}
|
||||
}
|
||||
|
||||
// Create your Player and MediaSession in the onCreate lifecycle event
|
||||
@@ -50,18 +79,6 @@ class PlaybackService : MediaSessionService() {
|
||||
super.onCreate()
|
||||
|
||||
Log.d("Lifetime Event", "PlaybackService.onCreate")
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClientManager.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun onProxyUpdated() {
|
||||
val toDestroyAllInOne = managerAllInOne
|
||||
|
||||
managerAllInOne = MultiPlayerPlaybackManager(CustomMediaSourceFactory(), videoViewedPositionCache)
|
||||
|
||||
toDestroyAllInOne?.releaseAppPlayers()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
@@ -73,9 +90,8 @@ class PlaybackService : MediaSessionService() {
|
||||
override fun onDestroy() {
|
||||
Log.d("Lifetime Event", "PlaybackService.onDestroy")
|
||||
|
||||
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
|
||||
managerAllInOne?.releaseAppPlayers()
|
||||
managerAllInOneProxy?.releaseAppPlayers()
|
||||
managerAllInOneNoProxy?.releaseAppPlayers()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -88,14 +104,28 @@ class PlaybackService : MediaSessionService() {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerAllInOne?.playingContent()?.forEach {
|
||||
managerAllInOneProxy?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerAllInOne?.playingContent()?.forEach {
|
||||
managerAllInOneProxy?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerAllInOneNoProxy?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerAllInOneNoProxy?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
@@ -108,8 +138,9 @@ class PlaybackService : MediaSessionService() {
|
||||
val id = controllerInfo.connectionHints.getString("id") ?: return null
|
||||
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
|
||||
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
|
||||
val proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
|
||||
|
||||
val manager = lazyDS()
|
||||
val manager = lazyDS(proxyPort)
|
||||
|
||||
return manager.getMediaSession(
|
||||
id,
|
||||
|
||||
@@ -43,13 +43,16 @@ class VideoCache {
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
suspend fun initFileCache(context: Context) {
|
||||
suspend fun initFileCache(
|
||||
context: Context,
|
||||
cachePath: File,
|
||||
) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
simpleCache =
|
||||
SimpleCache(
|
||||
File(context.cacheDir, "exoplayer"),
|
||||
cachePath,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider,
|
||||
)
|
||||
@@ -62,9 +65,8 @@ class VideoCache {
|
||||
CacheDataSource
|
||||
.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(client),
|
||||
).setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(client))
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
fun get(client: OkHttpClient): CacheDataSource.Factory {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTag
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType
|
||||
import okio.BufferedSource
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.Options
|
||||
import java.nio.charset.Charset
|
||||
|
||||
class HtmlParser {
|
||||
companion object {
|
||||
val ATTRIBUTE_VALUE_CHARSET = "charset"
|
||||
val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
|
||||
val CONTENT = "content"
|
||||
|
||||
// taken from okhttp
|
||||
private val UNICODE_BOMS =
|
||||
Options.of(
|
||||
// UTF-8
|
||||
"efbbbf".decodeHex(),
|
||||
// UTF-16BE
|
||||
"feff".decodeHex(),
|
||||
// UTF-16LE
|
||||
"fffe".decodeHex(),
|
||||
// UTF-32BE
|
||||
"0000ffff".decodeHex(),
|
||||
// UTF-32LE
|
||||
"ffff0000".decodeHex(),
|
||||
)
|
||||
|
||||
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
|
||||
}
|
||||
|
||||
suspend fun parseHtml(
|
||||
source: BufferedSource,
|
||||
type: MediaType,
|
||||
): Sequence<MetaTag> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// sniff charset from Content-Type header or BOM
|
||||
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
|
||||
if (sniffedCharset != null) {
|
||||
val content = source.readByteArray().toString(sniffedCharset)
|
||||
return@withContext MetaTagsParser.parse(content)
|
||||
}
|
||||
|
||||
// if sniffing was failed, detect charset from content
|
||||
val bodyBytes = source.readByteArray()
|
||||
val charset = detectCharset(bodyBytes)
|
||||
val content = bodyBytes.toString(charset)
|
||||
return@withContext MetaTagsParser.parse(content)
|
||||
}
|
||||
|
||||
private fun BufferedSource.readBomAsCharset(): Charset? =
|
||||
when (select(UNICODE_BOMS)) {
|
||||
0 -> Charsets.UTF_8
|
||||
1 -> Charsets.UTF_16BE
|
||||
2 -> Charsets.UTF_16LE
|
||||
3 -> Charsets.UTF_32BE
|
||||
4 -> Charsets.UTF_32LE
|
||||
-1 -> null
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
private fun detectCharset(bodyBytes: ByteArray): Charset {
|
||||
// try to detect charset from meta tags parsed from first 1024 bytes of body
|
||||
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
|
||||
val metaTags = MetaTagsParser.parse(firstPart)
|
||||
metaTags.forEach { meta ->
|
||||
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
|
||||
if (charsetAttr.isNotEmpty()) {
|
||||
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
|
||||
RE_CONTENT_TYPE_CHARSET
|
||||
.find(meta.attr(CONTENT))
|
||||
?.let {
|
||||
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
|
||||
}?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
// defaults to UTF-8
|
||||
return Charset.forName("utf-8")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTag
|
||||
|
||||
class OpenGraphParser {
|
||||
class Result(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val image: String,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
val ATTRIBUTE_VALUE_NAME = "name"
|
||||
val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
|
||||
|
||||
// for <meta itemprop=... to get title
|
||||
private val META_X_TITLE =
|
||||
arrayOf(
|
||||
"og:title",
|
||||
"twitter:title",
|
||||
"title",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get description
|
||||
private val META_X_DESCRIPTION =
|
||||
arrayOf(
|
||||
"og:description",
|
||||
"twitter:description",
|
||||
"description",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get image
|
||||
private val META_X_IMAGE =
|
||||
arrayOf(
|
||||
"og:image",
|
||||
"twitter:image",
|
||||
"image",
|
||||
)
|
||||
|
||||
private val CONTENT = "content"
|
||||
}
|
||||
|
||||
fun extractUrlInfo(metaTags: Sequence<MetaTag>): Result {
|
||||
var title: String = ""
|
||||
var description: String = ""
|
||||
var image: String = ""
|
||||
|
||||
metaTags.forEach {
|
||||
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return Result(title, description, image)
|
||||
}
|
||||
}
|
||||
return Result(title, description, image)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
|
||||
class UrlPreview {
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
onComplete: suspend (urlInfo: UrlInfoItem) -> Unit,
|
||||
onFailed: suspend (t: Throwable) -> Unit,
|
||||
) = try {
|
||||
onComplete(getDocument(url, forceProxy))
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
onFailed(t)
|
||||
}
|
||||
|
||||
suspend fun getDocument(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
if (it.isSuccessful) {
|
||||
val mimeType =
|
||||
it.headers["Content-Type"]?.toMediaType()
|
||||
?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}")
|
||||
if (mimeType.type == "text" && mimeType.subtype == "html") {
|
||||
val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType))
|
||||
UrlInfoItem(url, data.title, data.description, data.image, mimeType)
|
||||
} else if (mimeType.type == "image") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else if (mimeType.type == "video") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned: " + it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTag
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okio.BufferedSource
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.Options
|
||||
import java.nio.charset.Charset
|
||||
|
||||
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
private const val ATTRIBUTE_VALUE_NAME = "name"
|
||||
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
|
||||
private const val ATTRIBUTE_VALUE_CHARSET = "charset"
|
||||
private const val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
|
||||
|
||||
// for <meta itemprop=... to get title
|
||||
private val META_X_TITLE =
|
||||
arrayOf(
|
||||
"og:title",
|
||||
"twitter:title",
|
||||
"title",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get description
|
||||
private val META_X_DESCRIPTION =
|
||||
arrayOf(
|
||||
"og:description",
|
||||
"twitter:description",
|
||||
"description",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get image
|
||||
private val META_X_IMAGE =
|
||||
arrayOf(
|
||||
"og:image",
|
||||
"twitter:image",
|
||||
"image",
|
||||
)
|
||||
|
||||
private const val CONTENT = "content"
|
||||
|
||||
suspend fun getDocument(
|
||||
url: String,
|
||||
timeOut: Int = 30000,
|
||||
): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
if (it.isSuccessful) {
|
||||
val mimeType =
|
||||
it.headers["Content-Type"]?.toMediaType()
|
||||
?: throw IllegalArgumentException(
|
||||
"Website returned unknown mimetype: ${it.headers["Content-Type"]}",
|
||||
)
|
||||
if (mimeType.type == "text" && mimeType.subtype == "html") {
|
||||
parseHtml(url, it.body.source(), mimeType)
|
||||
} else if (mimeType.type == "image") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else if (mimeType.type == "video") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else {
|
||||
throw IllegalArgumentException(
|
||||
"Website returned unknown encoding for previews: $mimeType",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned: " + it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseHtml(
|
||||
url: String,
|
||||
source: BufferedSource,
|
||||
type: MediaType,
|
||||
): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
// sniff charset from Content-Type header or BOM
|
||||
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
|
||||
if (sniffedCharset != null) {
|
||||
val metaTags = MetaTagsParser.parse(source.readByteArray().toString(sniffedCharset))
|
||||
return@withContext extractUrlInfo(url, metaTags, type)
|
||||
}
|
||||
|
||||
// if sniffing was failed, detect charset from content
|
||||
val bodyBytes = source.readByteArray()
|
||||
val charset = detectCharset(bodyBytes)
|
||||
val metaTags = MetaTagsParser.parse(bodyBytes.toString(charset))
|
||||
return@withContext extractUrlInfo(url, metaTags, type)
|
||||
}
|
||||
|
||||
// taken from okhttp
|
||||
private val UNICODE_BOMS =
|
||||
Options.of(
|
||||
// UTF-8
|
||||
"efbbbf".decodeHex(),
|
||||
// UTF-16BE
|
||||
"feff".decodeHex(),
|
||||
// UTF-16LE
|
||||
"fffe".decodeHex(),
|
||||
// UTF-32BE
|
||||
"0000ffff".decodeHex(),
|
||||
// UTF-32LE
|
||||
"ffff0000".decodeHex(),
|
||||
)
|
||||
|
||||
private fun BufferedSource.readBomAsCharset(): Charset? =
|
||||
when (select(UNICODE_BOMS)) {
|
||||
0 -> Charsets.UTF_8
|
||||
1 -> Charsets.UTF_16BE
|
||||
2 -> Charsets.UTF_16LE
|
||||
3 -> Charsets.UTF_32BE
|
||||
4 -> Charsets.UTF_32LE
|
||||
-1 -> null
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
|
||||
|
||||
private fun detectCharset(bodyBytes: ByteArray): Charset {
|
||||
// try to detect charset from meta tags parsed from first 1024 bytes of body
|
||||
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
|
||||
val metaTags = MetaTagsParser.parse(firstPart)
|
||||
metaTags.forEach { meta ->
|
||||
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
|
||||
if (charsetAttr.isNotEmpty()) {
|
||||
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
|
||||
RE_CONTENT_TYPE_CHARSET
|
||||
.find(meta.attr(CONTENT))
|
||||
?.let {
|
||||
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
|
||||
}?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
// defaults to UTF-8
|
||||
return Charset.forName("utf-8")
|
||||
}
|
||||
|
||||
private fun extractUrlInfo(
|
||||
url: String,
|
||||
metaTags: Sequence<MetaTag>,
|
||||
type: MediaType,
|
||||
): UrlInfoItem {
|
||||
var title: String = ""
|
||||
var description: String = ""
|
||||
var image: String = ""
|
||||
|
||||
metaTags.forEach {
|
||||
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return UrlInfoItem(url, title, description, image, type)
|
||||
}
|
||||
}
|
||||
return UrlInfoItem(url, title, description, image, type)
|
||||
}
|
||||
@@ -26,11 +26,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@Composable
|
||||
@@ -42,10 +38,9 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
|
||||
|
||||
LaunchedEffect(key1 = sharedPreferencesViewModel) {
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = displayFeatures, key2 = windowSizeClass) {
|
||||
LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) {
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
@@ -55,20 +50,3 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
|
||||
|
||||
return sharedPreferencesViewModel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppScreen(
|
||||
sharedPreferencesViewModel: SharedPreferencesViewModel,
|
||||
serviceManager: ServiceManager,
|
||||
) {
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
accountStateViewModel.serviceManager = serviceManager
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,16 +34,22 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.debugState
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.debugState
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
@@ -67,8 +73,6 @@ class MainActivity : AppCompatActivity() {
|
||||
val isOnMobileDataState = mutableStateOf(false)
|
||||
private val isOnWifiDataState = mutableStateOf(false)
|
||||
|
||||
// Service Manager is only active when the activity is active.
|
||||
val serviceManager = ServiceManager()
|
||||
private var shouldPauseService = true
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@@ -80,7 +84,16 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
setContent {
|
||||
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
|
||||
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel, serviceManager = serviceManager)
|
||||
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +110,8 @@ class MainActivity : AppCompatActivity() {
|
||||
checkLanguage(locales.get(0).language)
|
||||
}
|
||||
|
||||
TorManager.startTor(this)
|
||||
|
||||
Log.d("Lifetime Event", "MainActivity.onResume")
|
||||
|
||||
// starts muted every time
|
||||
@@ -105,7 +120,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// Keep connection alive if it's calling the signer app
|
||||
Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService")
|
||||
if (shouldPauseService) {
|
||||
GlobalScope.launch(Dispatchers.IO) { serviceManager.justStart() }
|
||||
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.justStart() }
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
@@ -129,7 +144,7 @@ class MainActivity : AppCompatActivity() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LanguageTranslatorService.clear()
|
||||
}
|
||||
serviceManager.cleanObservers()
|
||||
Amethyst.instance.serviceManager.cleanObservers()
|
||||
|
||||
// if (BuildConfig.DEBUG) {
|
||||
GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) }
|
||||
@@ -137,12 +152,14 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService")
|
||||
if (shouldPauseService) {
|
||||
GlobalScope.launch(Dispatchers.IO) { serviceManager.pauseForGood() }
|
||||
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.pauseForGood() }
|
||||
}
|
||||
|
||||
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
|
||||
.unregisterNetworkCallback(networkCallback)
|
||||
|
||||
TorManager.stopTor(this)
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
@@ -175,18 +192,6 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Release memory when the UI becomes hidden or when system resources become low.
|
||||
*
|
||||
* @param level the memory-related event that was raised.
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() }
|
||||
}
|
||||
|
||||
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
|
||||
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
|
||||
@@ -228,7 +233,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService")
|
||||
if (shouldPauseService && lastNetwork != null && lastNetwork != network) {
|
||||
GlobalScope.launch(Dispatchers.IO) { serviceManager.forceRestart() }
|
||||
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.forceRestart() }
|
||||
}
|
||||
|
||||
lastNetwork = network
|
||||
@@ -247,7 +252,7 @@ class MainActivity : AppCompatActivity() {
|
||||
"onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}",
|
||||
)
|
||||
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
|
||||
serviceManager.forceRestart()
|
||||
Amethyst.instance.serviceManager.forceRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,7 +324,7 @@ fun uriToRoute(uri: String?): String? =
|
||||
uri?.let {
|
||||
Nip47WalletConnect.parse(it)
|
||||
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
|
||||
Route.Home.base + "?nip47=" + encodedUri
|
||||
Route.NIP47Setup.base + "?nip47=" + encodedUri
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
@@ -94,9 +94,10 @@ import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
@@ -109,10 +110,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -121,7 +120,7 @@ fun EditPostView(
|
||||
edit: Note,
|
||||
versionLookingAt: Note?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val postViewModel: EditPostViewModel = viewModel()
|
||||
postViewModel.prepare(edit, versionLookingAt, accountViewModel)
|
||||
@@ -135,12 +134,6 @@ fun EditPostView(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(edit, versionLookingAt, accountViewModel)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -266,11 +259,11 @@ fun EditPostView(
|
||||
Row(Modifier.heightIn(max = 200.dp)) {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = 1,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -337,15 +330,15 @@ fun EditPostView(
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
accountViewModel.account.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||
if (!server.isNip95) {
|
||||
accountViewModel.account.changeDefaultFileServer(server.server)
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server.server)
|
||||
}
|
||||
},
|
||||
onCancel = { postViewModel.contentToAddUrl = null },
|
||||
onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
|
||||
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -363,7 +356,7 @@ fun EditPostView(
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
user.pubkeyHex,
|
||||
accountViewModel.account,
|
||||
accountViewModel,
|
||||
stringRes(id = R.string.lightning_invoice),
|
||||
stringRes(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -42,14 +43,13 @@ import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@@ -68,8 +68,6 @@ open class EditPostViewModel : ViewModel() {
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError =
|
||||
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
@@ -109,11 +107,11 @@ open class EditPostViewModel : ViewModel() {
|
||||
editedFromNote = edit
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
fun sendPost(relayList: List<RelaySetupInfo>) {
|
||||
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
|
||||
}
|
||||
|
||||
suspend fun innerSendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
suspend fun innerSendPost(relayList: List<RelaySetupInfo>) {
|
||||
if (accountViewModel == null) {
|
||||
cancel()
|
||||
return
|
||||
@@ -150,8 +148,10 @@ open class EditPostViewModel : ViewModel() {
|
||||
galleryUri: Uri,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: Int,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
isUploadingImage = true
|
||||
@@ -169,7 +169,16 @@ open class EditPostViewModel : ViewModel() {
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.isNip95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
contentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -184,6 +193,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
@@ -193,6 +203,11 @@ open class EditPostViewModel : ViewModel() {
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -202,17 +217,16 @@ open class EditPostViewModel : ViewModel() {
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload: ${e.message}")
|
||||
}
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit(it) }
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
|
||||
},
|
||||
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -306,6 +320,9 @@ open class EditPostViewModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
@@ -334,7 +351,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -342,6 +359,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
forceProxy = forceProxy(imageUrl),
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
@@ -353,7 +371,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -363,10 +381,12 @@ open class EditPostViewModel : ViewModel() {
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Media is too big for NIP-95")
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
}
|
||||
return
|
||||
@@ -393,7 +413,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class ImageDownloader {
|
||||
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
|
||||
suspend fun waitAndGetImage(
|
||||
imageUrl: String,
|
||||
forceProxy: Boolean,
|
||||
): ByteArray? {
|
||||
var imageData: ByteArray? = null
|
||||
var tentatives = 0
|
||||
|
||||
@@ -35,11 +38,12 @@ class ImageDownloader {
|
||||
while (imageData == null && tentatives < 15) {
|
||||
imageData =
|
||||
try {
|
||||
// TODO: Migrate to OkHttp
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc =
|
||||
if (HttpClientManager.getDefaultProxy() != null) {
|
||||
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
|
||||
if (forceProxy) {
|
||||
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
@@ -52,8 +56,8 @@ class ImageDownloader {
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc =
|
||||
if (HttpClientManager.getDefaultProxy() != null) {
|
||||
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
|
||||
if (forceProxy) {
|
||||
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Done
|
||||
@@ -32,39 +35,92 @@ import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
@Composable
|
||||
fun InformationDialog(
|
||||
title: String,
|
||||
textContent: String?,
|
||||
throwable: Throwable,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val str = textContent ?: throwable.localizedMessage ?: throwable.message ?: throwable.javaClass.simpleName
|
||||
|
||||
val stack =
|
||||
remember(throwable) {
|
||||
val writer = StringWriter()
|
||||
writer.append("\n")
|
||||
|
||||
throwable.printStackTrace(PrintWriter(writer))
|
||||
|
||||
writer.toString()
|
||||
}
|
||||
|
||||
InformationDialog(title = title, textContent = str, moreInfo = stack, buttonColors, onDismiss)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InformationDialog(
|
||||
title: String,
|
||||
textContent: String,
|
||||
moreInfo: String? = null,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { SelectionContainer { Text(textContent) } },
|
||||
text = {
|
||||
SelectionContainer {
|
||||
Text(textContent)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = buttonColors,
|
||||
contentPadding = PaddingValues(horizontal = Size16dp),
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
moreInfo?.let {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
TextButton(onClick = {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
}) {
|
||||
Text(stringRes(R.string.copy_stack_to_clipboard))
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = buttonColors,
|
||||
contentPadding = PaddingValues(horizontal = Size16dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Done,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
Text(stringRes(R.string.error_dialog_button_ok))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Done,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
Text(stringRes(R.string.error_dialog_button_ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
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.ui.Alignment
|
||||
@@ -76,12 +75,13 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -103,7 +103,7 @@ import kotlinx.coroutines.withContext
|
||||
fun JoinUserOrChannelView(
|
||||
onClose: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val searchBarViewModel: SearchBarViewModel =
|
||||
viewModel(
|
||||
@@ -127,7 +127,7 @@ fun JoinUserOrChannelView(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
onClose: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
@@ -184,7 +184,7 @@ fun JoinUserOrChannelView(
|
||||
private fun RenderSearch(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -325,7 +325,7 @@ private fun RenderSearchResults(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
if (searchBarViewModel.isSearching) {
|
||||
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
|
||||
@@ -348,7 +348,7 @@ private fun RenderSearchResults(
|
||||
key = { _, item -> "u" + item.pubkeyHex },
|
||||
) { _, item ->
|
||||
UserComposeForChat(item, accountViewModel) {
|
||||
accountViewModel.createChatRoomFor(item) { nav("Room/$it") }
|
||||
accountViewModel.createChatRoomFor(item) { nav.nav("Room/$it") }
|
||||
|
||||
searchBarViewModel.clear()
|
||||
}
|
||||
@@ -367,7 +367,7 @@ private fun RenderSearchResults(
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
|
||||
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
|
||||
) {
|
||||
nav("Channel/${item.idHex}")
|
||||
nav.nav("Channel/${item.idHex}")
|
||||
searchBarViewModel.clear()
|
||||
}
|
||||
|
||||
@@ -387,8 +387,6 @@ private fun RenderChannel(
|
||||
loadRobohash: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val hasNewMessages = remember { mutableStateOf(false) }
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
@@ -400,7 +398,7 @@ private fun RenderChannel(
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.summary(),
|
||||
hasNewMessages,
|
||||
hasNewMessages = false,
|
||||
onClick = onClick,
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
loadRobohash = loadRobohash,
|
||||
|
||||
@@ -46,9 +46,10 @@ import okio.source
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object ImageSaver {
|
||||
fun saveImage(
|
||||
object MediaSaverToDisk {
|
||||
fun saveDownloadingIfNeeded(
|
||||
videoUri: String?,
|
||||
forceProxy: Boolean,
|
||||
mimeType: String?,
|
||||
localContext: Context,
|
||||
onSuccess: () -> Any?,
|
||||
@@ -56,14 +57,15 @@ object ImageSaver {
|
||||
) {
|
||||
if (videoUri != null) {
|
||||
if (!videoUri.startsWith("file")) {
|
||||
saveImage(
|
||||
downloadAndSave(
|
||||
context = localContext,
|
||||
forceProxy = forceProxy,
|
||||
url = videoUri,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
)
|
||||
} else {
|
||||
saveImage(
|
||||
save(
|
||||
context = localContext,
|
||||
localFile = videoUri.toUri().toFile(),
|
||||
mimeType = mimeType,
|
||||
@@ -79,13 +81,14 @@ object ImageSaver {
|
||||
*
|
||||
* @see PICTURES_SUBDIRECTORY
|
||||
*/
|
||||
fun saveImage(
|
||||
fun downloadAndSave(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
|
||||
val request =
|
||||
Request
|
||||
@@ -142,7 +145,7 @@ object ImageSaver {
|
||||
)
|
||||
}
|
||||
|
||||
fun saveImage(
|
||||
fun save(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
@@ -64,24 +64,23 @@ open class NewMediaModel : ViewModel() {
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
var onError: (String) -> Unit = {}
|
||||
|
||||
open fun load(
|
||||
account: Account,
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = ServerOption(defaultServer(), false)
|
||||
this.onError = onError
|
||||
}
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
mediaQuality: Int,
|
||||
onError: (String) -> Unit = {},
|
||||
) {
|
||||
isUploadingImage = true
|
||||
|
||||
@@ -110,6 +109,7 @@ open class NewMediaModel : ViewModel() {
|
||||
alt,
|
||||
sensitiveContent,
|
||||
relayList = relayList,
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
}
|
||||
@@ -136,6 +136,7 @@ open class NewMediaModel : ViewModel() {
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = serverToUse.server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = { percent: Float ->
|
||||
uploadingPercentage.value = 0.2f + (0.2f * percent)
|
||||
},
|
||||
@@ -148,6 +149,8 @@ open class NewMediaModel : ViewModel() {
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
relayList = relayList,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -166,6 +169,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingDescription.value = null
|
||||
onError(stringRes(context, R.string.error_when_compressing_media, it))
|
||||
},
|
||||
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +192,9 @@ open class NewMediaModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
@@ -230,7 +236,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl, forceProxy(imageUrl))
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
@@ -281,12 +287,13 @@ open class NewMediaModel : ViewModel() {
|
||||
mimeType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
onError("Media is too big for NIP-95")
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
@@ -331,7 +338,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
|
||||
fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
|
||||
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
|
||||
@@ -44,22 +44,26 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.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.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -67,10 +71,12 @@ import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -84,7 +90,7 @@ fun NewMediaView(
|
||||
onClose: () -> Unit,
|
||||
postViewModel: NewMediaModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
@@ -94,14 +100,15 @@ fun NewMediaView(
|
||||
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType) {
|
||||
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
}
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
}
|
||||
|
||||
var showRelaysDialog by remember { mutableStateOf(false) }
|
||||
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||
var mediaQualitySlider by remember { mutableIntStateOf(1) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties =
|
||||
@@ -161,10 +168,12 @@ fun NewMediaView(
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context, relayList)
|
||||
postViewModel.upload(context, relayList, mediaQualitySlider) {
|
||||
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
}
|
||||
postViewModel.selectedServer?.let {
|
||||
if (!it.isNip95) {
|
||||
account.changeDefaultFileServer(it.server)
|
||||
account.settings.changeDefaultFileServer(it.server)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -179,6 +188,60 @@ fun NewMediaView(
|
||||
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
|
||||
) {
|
||||
ImageVideoPost(postViewModel, accountViewModel)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(context, R.string.media_compression_quality_label),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(context, R.string.media_compression_quality_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
when (mediaQualitySlider) {
|
||||
0 -> stringRes(R.string.media_compression_quality_low)
|
||||
1 -> stringRes(R.string.media_compression_quality_medium)
|
||||
2 -> stringRes(R.string.media_compression_quality_high)
|
||||
3 -> stringRes(R.string.media_compression_quality_uncompressed)
|
||||
else -> stringRes(R.string.media_compression_quality_medium)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
Slider(
|
||||
value = mediaQualitySlider.toFloat(),
|
||||
onValueChange = { mediaQualitySlider = it.toInt() },
|
||||
valueRange = 0f..3f,
|
||||
steps = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,7 +353,7 @@ fun ImageVideoPost(
|
||||
label = stringRes(id = R.string.file_server),
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it.server == accountViewModel.account.defaultFileServer }
|
||||
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
|
||||
?.server
|
||||
?.name
|
||||
?: fileServers[0].server.name,
|
||||
|
||||
@@ -27,6 +27,8 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
@@ -56,6 +58,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.filled.LocationOff
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
@@ -88,6 +91,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.rememberCoroutineScope
|
||||
@@ -117,6 +121,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
@@ -135,6 +140,7 @@ import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.CancelIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.CloseIcon
|
||||
@@ -145,10 +151,10 @@ import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.MyTextField
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
@@ -180,7 +186,6 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.Math.round
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
|
||||
@@ -194,8 +199,9 @@ fun NewPostView(
|
||||
draft: Note? = null,
|
||||
enableMessageInterface: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
postViewModel.wantsDirectMessage = enableMessageInterface
|
||||
|
||||
@@ -205,6 +211,9 @@ fun NewPostView(
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRelaysDialog by remember { mutableStateOf(false) }
|
||||
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
|
||||
var showCamera by remember {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = postViewModel.draftTag) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -220,10 +229,6 @@ fun NewPostView(
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft)
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,11 +361,11 @@ fun NewPostView(
|
||||
Row(Modifier.heightIn(max = 200.dp)) {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = 1,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -496,15 +501,15 @@ fun NewPostView(
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
accountViewModel.account.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||
if (!server.isNip95) {
|
||||
accountViewModel.account.changeDefaultFileServer(server.server)
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server.server)
|
||||
}
|
||||
},
|
||||
onCancel = { postViewModel.contentToAddUrl = null },
|
||||
onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
|
||||
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -520,7 +525,7 @@ fun NewPostView(
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
accountViewModel.account.userProfile().pubkeyHex,
|
||||
accountViewModel.account,
|
||||
accountViewModel,
|
||||
stringRes(id = R.string.lightning_invoice),
|
||||
stringRes(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
@@ -566,7 +571,6 @@ fun NewPostView(
|
||||
@Composable
|
||||
private fun BottomRowActions(postViewModel: NewPostViewModel) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -583,6 +587,12 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
TakePictureButton(
|
||||
onPictureTaken = {
|
||||
postViewModel.selectImage(it)
|
||||
},
|
||||
)
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
// These should be hashtag recommendations the user selects in the future.
|
||||
// val hashtag = stringRes(R.string.poll_hashtag)
|
||||
@@ -628,6 +638,59 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun TakePictureButton(onPictureTaken: (Uri) -> Unit) {
|
||||
var imageUri by remember { mutableStateOf<Uri?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture(),
|
||||
) { success ->
|
||||
if (success) {
|
||||
imageUri?.let {
|
||||
onPictureTaken(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val cameraPermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.CAMERA,
|
||||
onPermissionResult = {
|
||||
if (it) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
imageUri = getPhotoUri(context)
|
||||
imageUri?.let { uri -> launcher.launch(uri) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
imageUri = getPhotoUri(context)
|
||||
imageUri?.let { uri -> launcher.launch(uri) }
|
||||
}
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = stringRes(id = R.string.upload_image),
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollField(postViewModel: NewPostViewModel) {
|
||||
val optionsList = postViewModel.pollOptions
|
||||
@@ -1614,9 +1677,9 @@ fun CreateButton(
|
||||
fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
defaultServer: Nip96MediaServers.ServerName,
|
||||
onAdd: (String, ServerOption, Boolean) -> Unit,
|
||||
onAdd: (String, ServerOption, Boolean, Int) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onError: (Int) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
@@ -1659,10 +1722,23 @@ fun ImageVideoDescription(
|
||||
fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList()
|
||||
}
|
||||
|
||||
var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) }
|
||||
var selectedServer by remember {
|
||||
mutableStateOf(
|
||||
ServerOption(
|
||||
fileServers
|
||||
.firstOrNull { it.server == defaultServer }
|
||||
?.server
|
||||
?: fileServers[0].server,
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
var message by remember { mutableStateOf("") }
|
||||
var sensitiveContent by remember { mutableStateOf(false) }
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||
var mediaQualitySlider by remember { mutableIntStateOf(1) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -1753,7 +1829,7 @@ fun ImageVideoDescription(
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Unable to load thumbnail")
|
||||
onError(R.string.unable_to_load_thumbnail)
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
@@ -1783,7 +1859,7 @@ fun ImageVideoDescription(
|
||||
label = stringRes(id = R.string.file_server),
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it.server == accountViewModel.account.defaultFileServer }
|
||||
.firstOrNull { it.server == defaultServer }
|
||||
?.server
|
||||
?.name
|
||||
?: fileServers[0].server.name,
|
||||
@@ -1840,12 +1916,67 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_label),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
when (mediaQualitySlider) {
|
||||
0 -> stringRes(R.string.media_compression_quality_low)
|
||||
1 -> stringRes(R.string.media_compression_quality_medium)
|
||||
2 -> stringRes(R.string.media_compression_quality_high)
|
||||
3 -> stringRes(R.string.media_compression_quality_uncompressed)
|
||||
else -> stringRes(R.string.media_compression_quality_medium)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
Slider(
|
||||
value = mediaQualitySlider.toFloat(),
|
||||
onValueChange = { mediaQualitySlider = it.toInt() },
|
||||
valueRange = 0f..3f,
|
||||
steps = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = { onAdd(message, selectedServer, sensitiveContent) },
|
||||
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider) },
|
||||
shape = QuoteBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -48,6 +49,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
@@ -67,15 +69,15 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.events.findURLs
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -109,8 +111,6 @@ open class NewPostViewModel : ViewModel() {
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError =
|
||||
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
@@ -473,7 +473,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
fun sendPost(relayList: List<RelaySetupInfo>) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
innerSendPost(relayList, null)
|
||||
accountViewModel?.deleteDraft(draftTag)
|
||||
@@ -481,18 +481,18 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDraft(relayList: List<RelaySetupInfo>? = null) {
|
||||
fun sendDraft(relayList: List<RelaySetupInfo>) {
|
||||
viewModelScope.launch {
|
||||
sendDraftSync(relayList)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync(relayList: List<RelaySetupInfo>? = null) {
|
||||
suspend fun sendDraftSync(relayList: List<RelaySetupInfo>) {
|
||||
innerSendPost(relayList, draftTag)
|
||||
}
|
||||
|
||||
private suspend fun innerSendPost(
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
localDraft: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (accountViewModel == null) {
|
||||
@@ -683,6 +683,74 @@ open class NewPostViewModel : ViewModel() {
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (originalNote?.event is TorrentCommentEvent) {
|
||||
val originalNoteEvent = originalNote?.event as TorrentCommentEvent
|
||||
// adds markers
|
||||
val rootId =
|
||||
originalNoteEvent.torrent() // if it has a marker as root
|
||||
?: originalNote
|
||||
?.replyTo
|
||||
?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }
|
||||
?.idHex // if it has loaded events with zero replies in the reply list
|
||||
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
|
||||
?: originalNote?.idHex
|
||||
|
||||
if (rootId != null) {
|
||||
// There must be a torrent ID
|
||||
val replyId = originalNote?.idHex
|
||||
|
||||
val replyToSet =
|
||||
if (forkedFromNote != null) {
|
||||
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
|
||||
} else {
|
||||
tagger.eTags
|
||||
}
|
||||
|
||||
account?.sendTorrentComment(
|
||||
message = tagger.message,
|
||||
replyTo = replyToSet,
|
||||
mentions = tagger.pTags,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
replyingTo = replyId,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
} else if (originalNote?.event is TorrentEvent) {
|
||||
val originalNoteEvent = originalNote?.event as TorrentEvent
|
||||
// adds markers
|
||||
val rootId = originalNoteEvent.id
|
||||
|
||||
val replyToSet =
|
||||
if (forkedFromNote != null) {
|
||||
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
|
||||
} else {
|
||||
tagger.eTags
|
||||
}
|
||||
|
||||
account?.sendTorrentComment(
|
||||
message = tagger.message,
|
||||
replyTo = replyToSet,
|
||||
mentions = tagger.pTags,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
replyingTo = null,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(
|
||||
@@ -766,8 +834,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
galleryUri: Uri,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: Int,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
isUploadingImage = true
|
||||
@@ -785,7 +855,16 @@ open class NewPostViewModel : ViewModel() {
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.isNip95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
contentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -800,6 +879,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
@@ -809,6 +889,11 @@ open class NewPostViewModel : ViewModel() {
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -818,17 +903,16 @@ open class NewPostViewModel : ViewModel() {
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload: ${e.message}")
|
||||
}
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit(it) }
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
|
||||
},
|
||||
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1064,6 +1148,9 @@ open class NewPostViewModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
@@ -1092,7 +1179,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1100,6 +1187,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
forceProxy = forceProxy(imageUrl),
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
@@ -1112,7 +1200,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1126,10 +1214,12 @@ open class NewPostViewModel : ViewModel() {
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onError: (message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Media is too big for NIP-95")
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
}
|
||||
return
|
||||
@@ -1157,7 +1247,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -46,29 +45,20 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun NewUserMetadataView(
|
||||
onClose: () -> Unit,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val postViewModel: NewUserMetadataViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
postViewModel.load(accountViewModel.account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
@@ -164,7 +154,7 @@ fun NewUserMetadataView(
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
) {
|
||||
postViewModel.uploadForPicture(it, context)
|
||||
postViewModel.uploadForPicture(it, context, onError = accountViewModel::toast)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -189,7 +179,7 @@ fun NewUserMetadataView(
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
) {
|
||||
postViewModel.uploadForBanner(it, context)
|
||||
postViewModel.uploadForBanner(it, context, onError = accountViewModel::toast)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -235,7 +225,7 @@ fun NewUserMetadataView(
|
||||
onValueChange = { postViewModel.lnAddress.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "me@mylightiningnode.com",
|
||||
text = "me@mylightningnode.com",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -27,15 +27,16 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.events.GitHubIdentity
|
||||
import com.vitorpamplona.quartz.events.MastodonIdentity
|
||||
import com.vitorpamplona.quartz.events.TwitterIdentity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -60,8 +61,6 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
|
||||
var isUploadingImageForPicture by mutableStateOf(false)
|
||||
var isUploadingImageForBanner by mutableStateOf(false)
|
||||
val imageUploadingError =
|
||||
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
@@ -130,6 +129,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
fun uploadForPicture(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
upload(
|
||||
@@ -137,6 +137,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context,
|
||||
onUploading = { isUploadingImageForPicture = it },
|
||||
onUploaded = { picture.value = it },
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +145,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
fun uploadForBanner(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
upload(
|
||||
@@ -151,6 +153,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context,
|
||||
onUploading = { isUploadingImageForBanner = it },
|
||||
onUploaded = { banner.value = it },
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -160,6 +163,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context: Context,
|
||||
onUploading: (Boolean) -> Unit,
|
||||
onUploaded: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
@@ -181,8 +185,9 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.defaultFileServer,
|
||||
server = account.settings.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
@@ -194,23 +199,22 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
onUploaded(url)
|
||||
} else {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
onUploading(false)
|
||||
viewModelScope.launch { imageUploadingError.emit(it) }
|
||||
|
||||
onError(stringRes(context, R.string.error_when_compressing_media), stringRes(context, it))
|
||||
},
|
||||
// Use MEDIUM quality as default
|
||||
mediaQuality = CompressorQuality.MEDIUM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
@@ -52,7 +54,7 @@ fun NotifyRequestDialog(
|
||||
textContent: String,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
@@ -72,7 +74,7 @@ fun NotifyRequestDialog(
|
||||
id = textContent,
|
||||
callbackUri = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
nav = rememberExtendedNav(nav, onDismiss),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.RelayInformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -74,7 +75,7 @@ fun RelaySelectionDialog(
|
||||
onClose: () -> Unit,
|
||||
onPost: (list: ImmutableList<RelaySetupInfo>) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A button to save the remote image to the gallery. May require a storage permission.
|
||||
*
|
||||
* @param url URL of the image
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(url: String) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
url = url,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.image_saved_to_the_gallery),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.failed_to_save_the_image),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
saveImage()
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.save))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.image_saved_to_the_gallery),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.failed_to_save_the_image),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
saveImage()
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.save))
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -52,6 +54,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
@@ -60,6 +63,10 @@ import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@@ -125,6 +132,23 @@ private fun UploadBoxButton(
|
||||
}
|
||||
}
|
||||
|
||||
fun getPhotoUri(context: Context): Uri {
|
||||
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File
|
||||
.createTempFile(
|
||||
"JPEG_${timeStamp}_",
|
||||
".jpg",
|
||||
storageDir,
|
||||
).let {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val DefaultAnimationColors =
|
||||
listOf(
|
||||
Color(0xFF5851D8),
|
||||
|
||||
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
|
||||
@@ -70,7 +71,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
fun MediaServersListView(
|
||||
onClose: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val mediaServersViewModel: MediaServersViewModel = viewModel()
|
||||
val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
@@ -55,7 +56,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
fun AddDMRelayListDialog(
|
||||
onClose: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val postViewModel: DMRelayListViewModel = viewModel()
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
@@ -55,7 +56,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
fun AddSearchRelayListDialog(
|
||||
onClose: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val postViewModel: SearchRelayListViewModel = viewModel()
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -61,13 +63,23 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.ammolite.relays.Constants
|
||||
import com.vitorpamplona.ammolite.relays.RelayStat
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AllRelayListView(
|
||||
onClose: () -> Unit,
|
||||
relayToAdd: String = "",
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
MappedAllRelayListView(onClose, relayToAdd, accountViewModel, rememberExtendedNav(nav, onClose))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MappedAllRelayListView(
|
||||
onClose: () -> Unit,
|
||||
relayToAdd: String = "",
|
||||
accountViewModel: AccountViewModel,
|
||||
newNav: INav,
|
||||
) {
|
||||
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
|
||||
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
|
||||
@@ -179,7 +191,7 @@ fun AllRelayListView(
|
||||
Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, onClose, nav)
|
||||
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
@@ -187,7 +199,7 @@ fun AllRelayListView(
|
||||
stringRes(R.string.public_notif_section_explainer),
|
||||
)
|
||||
}
|
||||
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, onClose, nav)
|
||||
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
@@ -195,7 +207,7 @@ fun AllRelayListView(
|
||||
stringRes(R.string.private_inbox_section_explainer),
|
||||
)
|
||||
}
|
||||
renderDMItems(dmFeedState, dmViewModel, accountViewModel, onClose, nav)
|
||||
renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
@@ -203,7 +215,7 @@ fun AllRelayListView(
|
||||
stringRes(R.string.private_outbox_section_explainer),
|
||||
)
|
||||
}
|
||||
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, onClose, nav)
|
||||
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
@@ -214,7 +226,7 @@ fun AllRelayListView(
|
||||
},
|
||||
)
|
||||
}
|
||||
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, onClose, nav)
|
||||
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
@@ -222,7 +234,7 @@ fun AllRelayListView(
|
||||
stringRes(R.string.local_section_explainer),
|
||||
)
|
||||
}
|
||||
renderLocalItems(localFeedState, localViewModel, accountViewModel, onClose, nav)
|
||||
renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
@@ -233,7 +245,7 @@ fun AllRelayListView(
|
||||
},
|
||||
)
|
||||
}
|
||||
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, onClose, nav, relayToAdd)
|
||||
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd)
|
||||
|
||||
if (kind3Proposals.isNotEmpty()) {
|
||||
item {
|
||||
@@ -242,7 +254,7 @@ fun AllRelayListView(
|
||||
stringRes(R.string.kind_3_recommended_section_description),
|
||||
)
|
||||
}
|
||||
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, onClose, nav)
|
||||
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ data class Kind3RelayProposalSetupInfo(
|
||||
val feedTypes: Set<FeedType>,
|
||||
val relayStat: RelayStat,
|
||||
val paidRelay: Boolean = false,
|
||||
val users: Set<HexKey>,
|
||||
val users: List<HexKey>,
|
||||
) {
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
@@ -20,28 +20,31 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun BasicRelaySetupInfoClickableRow(
|
||||
item: BasicRelaySetupInfo,
|
||||
@@ -51,25 +54,34 @@ fun BasicRelaySetupInfoClickableRow(
|
||||
onClick: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
clipboardManager.setText(AnnotatedString(item.briefInfo.url))
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
modifier = HalfVertPadding,
|
||||
) {
|
||||
Column(Modifier.clickable(onClick = onClick)) {
|
||||
val iconUrlFromRelayInfoDoc =
|
||||
remember(item) {
|
||||
Nip11CachedRetriever.getFromCache(item.url)?.icon
|
||||
}
|
||||
val iconUrlFromRelayInfoDoc =
|
||||
remember(item) {
|
||||
Nip11CachedRetriever.getFromCache(item.url)?.icon
|
||||
}
|
||||
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
loadRobohash,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
}
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
loadRobohash,
|
||||
item.relayStat.pingInMs,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
|
||||
Spacer(modifier = HalfHorzPadding)
|
||||
|
||||
@@ -78,12 +90,12 @@ fun BasicRelaySetupInfoClickableRow(
|
||||
item,
|
||||
onClick,
|
||||
onDelete,
|
||||
ReactionRowHeightChat.fillMaxWidth(),
|
||||
ReactionRowHeightChatMaxWidth,
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat.fillMaxWidth(),
|
||||
modifier = ReactionRowHeightChatMaxWidth,
|
||||
) {
|
||||
RelayStatusRow(
|
||||
item = item,
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
|
||||
@@ -39,7 +40,7 @@ fun BasicRelaySetupInfoDialog(
|
||||
item: BasicRelaySetupInfo,
|
||||
onDelete: (BasicRelaySetupInfo) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -64,6 +64,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
|
||||
_relays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
forceProxy = account.shouldUseTorForDirty(item.url),
|
||||
onInfo = {
|
||||
togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
|
||||
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -38,15 +40,16 @@ fun DMRelayList(
|
||||
postViewModel: DMRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val newNav = rememberExtendedNav(nav, onClose)
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderDMItems(feedState, postViewModel, accountViewModel, onClose, nav)
|
||||
renderDMItems(feedState, postViewModel, accountViewModel, newNav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,18 +58,15 @@ fun LazyListScope.renderDMItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: DMRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
|
||||
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -59,8 +58,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -70,6 +71,8 @@ import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -78,7 +81,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -101,12 +104,14 @@ fun Kind3RelayListView(
|
||||
postViewModel: Kind3RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
relayToAdd: String,
|
||||
) {
|
||||
val newNav = rememberExtendedNav(nav, onClose)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(contentPadding = FeedPadding) {
|
||||
renderKind3Items(feedState, postViewModel, accountViewModel, onClose, nav, relayToAdd)
|
||||
renderKind3Items(feedState, postViewModel, accountViewModel, newNav, relayToAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,8 +120,7 @@ fun LazyListScope.renderKind3Items(
|
||||
feedState: List<Kind3BasicRelaySetupInfo>,
|
||||
postViewModel: Kind3RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
relayToAdd: String,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "kind3" + item.url }) { index, item ->
|
||||
@@ -131,10 +135,8 @@ fun LazyListScope.renderKind3Items(
|
||||
onToggleSearch = { postViewModel.toggleSearch(it) },
|
||||
onDelete = { postViewModel.deleteRelay(it) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -147,8 +149,7 @@ fun LazyListScope.renderKind3ProposalItems(
|
||||
feedState: List<Kind3RelayProposalSetupInfo>,
|
||||
postViewModel: Kind3RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "kind3proposal" + item.url }) { index, item ->
|
||||
Kind3RelaySetupInfoProposalDialog(
|
||||
@@ -157,10 +158,7 @@ fun LazyListScope.renderKind3ProposalItems(
|
||||
postViewModel.addRelay(item)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = {
|
||||
onClose()
|
||||
nav(it)
|
||||
},
|
||||
nav = nav,
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
@@ -213,7 +211,7 @@ fun LoadRelayInfo(
|
||||
onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
|
||||
val context = LocalContext.current
|
||||
@@ -292,6 +290,7 @@ fun LoadRelayInfo(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ClickableRelayItem(
|
||||
item: Kind3BasicRelaySetupInfo,
|
||||
@@ -307,34 +306,43 @@ fun ClickableRelayItem(
|
||||
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
clipboardManager.setText(AnnotatedString(item.briefInfo.url))
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
Column(Modifier.clickable(onClick = onClick)) {
|
||||
val iconUrlFromRelayInfoDoc =
|
||||
remember(item) {
|
||||
Nip11CachedRetriever.getFromCache(item.url)?.icon
|
||||
}
|
||||
val iconUrlFromRelayInfoDoc =
|
||||
remember(item) {
|
||||
Nip11CachedRetriever.getFromCache(item.url)?.icon
|
||||
}
|
||||
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
loadRobohash,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
}
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
loadRobohash,
|
||||
item.relayStat.pingInMs,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
|
||||
Spacer(modifier = HalfHorzPadding)
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth())
|
||||
FirstLine(item, onClick, onDelete, ReactionRowHeightChatMaxWidth)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat.fillMaxWidth(),
|
||||
modifier = ReactionRowHeightChatMaxWidth,
|
||||
) {
|
||||
ActiveToggles(
|
||||
item = item,
|
||||
@@ -347,7 +355,7 @@ fun ClickableRelayItem(
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat.fillMaxWidth(),
|
||||
modifier = ReactionRowHeightChatMaxWidth,
|
||||
) {
|
||||
StatusRow(
|
||||
item = item,
|
||||
@@ -678,6 +686,7 @@ private fun ActiveToggles(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FirstLine(
|
||||
item: Kind3BasicRelaySetupInfo,
|
||||
@@ -685,11 +694,18 @@ private fun FirstLine(
|
||||
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = item.briefInfo.displayUrl,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
modifier =
|
||||
Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
clipboardManager.setText(AnnotatedString(item.briefInfo.url))
|
||||
},
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
@@ -80,6 +80,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
_relays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
forceProxy = account.shouldUseTorForDirty(item.url),
|
||||
onInfo = {
|
||||
togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
@@ -98,7 +99,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
relayFile
|
||||
.map {
|
||||
val localInfoFeedTypes =
|
||||
account.localRelays
|
||||
account.settings.localRelays
|
||||
.filter { localRelay -> localRelay.url == it.key }
|
||||
.firstOrNull()
|
||||
?.feedTypes
|
||||
@@ -119,7 +120,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
.sortedBy { it.relayStat.receivedBytes }
|
||||
.reversed()
|
||||
} else {
|
||||
account.localRelays
|
||||
account.settings.localRelays
|
||||
.map {
|
||||
Kind3BasicRelaySetupInfo(
|
||||
url = RelayUrlFormatter.normalize(it.url),
|
||||
@@ -164,7 +165,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
write = false,
|
||||
feedTypes = setOf(FeedType.FOLLOWS),
|
||||
relayStat = RelayStats.get(it.url),
|
||||
users = it.users,
|
||||
users = it.users.sorted(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
|
||||
@@ -39,7 +40,7 @@ fun Kind3RelaySetupInfoProposalDialog(
|
||||
item: Kind3RelayProposalSetupInfo,
|
||||
onAdd: (Kind3RelayProposalSetupInfo) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -22,12 +22,14 @@ package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -41,18 +43,27 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.AddRelayButton
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.RelayStat
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -63,66 +74,57 @@ fun Kind3RelaySetupInfoProposalRow(
|
||||
onAdd: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
Column(Modifier.clickable(onClick = onClick)) {
|
||||
val iconUrlFromRelayInfoDoc =
|
||||
remember(item) {
|
||||
Nip11CachedRetriever.getFromCache(item.url)?.icon
|
||||
}
|
||||
val iconUrlFromRelayInfoDoc =
|
||||
remember(item) {
|
||||
Nip11CachedRetriever.getFromCache(item.url)?.icon
|
||||
}
|
||||
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
loadRobohash,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
}
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
loadRobohash,
|
||||
item.relayStat.pingInMs,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
|
||||
Spacer(modifier = HalfHorzPadding)
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat.fillMaxWidth()) {
|
||||
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = item.briefInfo.displayUrl,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(ReactionRowHeightChatMaxWidth, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = item.briefInfo.displayUrl,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (item.paidRelay) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Paid,
|
||||
null,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 5.dp, top = 1.dp)
|
||||
.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.allGoodColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(verticalArrangement = Arrangement.Center) {
|
||||
item.users.forEach {
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
if (item.paidRelay) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Paid,
|
||||
null,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 5.dp, top = 1.dp)
|
||||
.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.allGoodColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UsedBy(item, accountViewModel, nav)
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(start = 10.dp),
|
||||
@@ -134,3 +136,67 @@ fun Kind3RelaySetupInfoProposalRow(
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UsedByPreview() {
|
||||
ThemeComparisonColumn {
|
||||
UsedBy(
|
||||
item =
|
||||
Kind3RelayProposalSetupInfo(
|
||||
"wss://nos.lol",
|
||||
true,
|
||||
true,
|
||||
COMMON_FEED_TYPES,
|
||||
relayStat = RelayStat(),
|
||||
paidRelay = false,
|
||||
users = listOf("User1", "User2", "User3", "User4"),
|
||||
),
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
nav = EmptyNav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UsedBy(
|
||||
item: Kind3RelayProposalSetupInfo,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
FlowRow(verticalArrangement = Arrangement.Center) {
|
||||
item.users.getOrNull(0)?.let {
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
item.users.getOrNull(1)?.let {
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
item.users.getOrNull(2)?.let {
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
if (item.users.size > 3) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.height(Size25dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.and_more, item.users.size - 3),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -38,15 +40,16 @@ fun LocalRelayList(
|
||||
postViewModel: LocalRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val newNav = rememberExtendedNav(nav, onClose)
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderLocalItems(feedState, postViewModel, accountViewModel, onClose, nav)
|
||||
renderLocalItems(feedState, postViewModel, accountViewModel, newNav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,18 +58,15 @@ fun LazyListScope.renderLocalItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: LocalRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
|
||||
override fun getRelayList(): List<String>? = account.localRelayServers.toList()
|
||||
override fun getRelayList(): List<String> = account.settings.localRelayServers.toList()
|
||||
|
||||
override fun saveRelayList(urlList: List<String>) {
|
||||
account.updateLocalRelayServers(urlList.toSet())
|
||||
account.settings.updateLocalRelayServers(urlList.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -38,8 +40,10 @@ fun Nip65RelayList(
|
||||
postViewModel: Nip65RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val newNav = rememberExtendedNav(nav, onClose)
|
||||
|
||||
val homeFeedState by postViewModel.homeRelays.collectAsStateWithLifecycle()
|
||||
val notifFeedState by postViewModel.notificationRelays.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -47,8 +51,8 @@ fun Nip65RelayList(
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderNip65HomeItems(homeFeedState, postViewModel, accountViewModel, onClose, nav)
|
||||
renderNip65NotifItems(notifFeedState, postViewModel, accountViewModel, onClose, nav)
|
||||
renderNip65HomeItems(homeFeedState, postViewModel, accountViewModel, newNav)
|
||||
renderNip65NotifItems(notifFeedState, postViewModel, accountViewModel, newNav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,18 +61,15 @@ fun LazyListScope.renderNip65HomeItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: Nip65RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteHomeRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -81,18 +82,15 @@ fun LazyListScope.renderNip65NotifItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: Nip65RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteNotifRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
|
||||
@@ -83,6 +83,7 @@ class Nip65RelayListViewModel : ViewModel() {
|
||||
_homeRelays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
forceProxy = account.shouldUseTorForDirty(item.url),
|
||||
onInfo = {
|
||||
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
@@ -93,6 +94,7 @@ class Nip65RelayListViewModel : ViewModel() {
|
||||
_notificationRelays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
forceProxy = account.shouldUseTorForDirty(item.url),
|
||||
onInfo = {
|
||||
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
|
||||
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -38,15 +40,16 @@ fun PrivateOutboxRelayList(
|
||||
postViewModel: PrivateOutboxRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val newNav = rememberExtendedNav(nav, onClose)
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderPrivateOutboxItems(feedState, postViewModel, accountViewModel, onClose, nav)
|
||||
renderPrivateOutboxItems(feedState, postViewModel, accountViewModel, newNav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,18 +58,15 @@ fun LazyListScope.renderPrivateOutboxItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: PrivateOutboxRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Outbox" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -54,11 +55,13 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
@@ -78,8 +81,10 @@ fun RelayInformationDialog(
|
||||
relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
|
||||
relayInfo: Nip11RelayInformation,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
val newNav = rememberExtendedNav(nav, onClose)
|
||||
|
||||
val messages =
|
||||
remember(relayBriefInfo) {
|
||||
RelayStats
|
||||
@@ -134,6 +139,7 @@ fun RelayInformationDialog(
|
||||
iconUrl = relayInfo.icon ?: relayBriefInfo.favIcon,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
|
||||
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
|
||||
RelayStats.get(url = relayBriefInfo.url).pingInMs,
|
||||
iconModifier = MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
}
|
||||
@@ -141,9 +147,8 @@ fun RelayInformationDialog(
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row { Title(relayInfo.name?.trim() ?: "") }
|
||||
|
||||
Row { SubtitleContent(relayInfo.description?.trim() ?: "") }
|
||||
Title(relayInfo.name?.trim() ?: "")
|
||||
SubtitleContent(relayInfo.description?.trim() ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,10 +156,7 @@ fun RelayInformationDialog(
|
||||
Section(stringRes(R.string.owner))
|
||||
|
||||
relayInfo.pubkey?.let {
|
||||
DisplayOwnerInformation(it, accountViewModel) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
DisplayOwnerInformation(it, accountViewModel, newNav)
|
||||
}
|
||||
}
|
||||
item {
|
||||
@@ -286,20 +288,22 @@ fun RelayInformationDialog(
|
||||
|
||||
items(messages) { msg ->
|
||||
Row {
|
||||
TranslatableRichTextViewer(
|
||||
content =
|
||||
remember {
|
||||
"${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}"
|
||||
},
|
||||
canPreview = false,
|
||||
quotesLeft = 0,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = color,
|
||||
id = msg.hashCode().toString(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
SelectionContainer {
|
||||
TranslatableRichTextViewer(
|
||||
content =
|
||||
remember {
|
||||
"${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}"
|
||||
},
|
||||
canPreview = false,
|
||||
quotesLeft = 0,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = color,
|
||||
id = msg.hashCode().toString(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = newNav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
@@ -350,7 +354,7 @@ private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) {
|
||||
private fun DisplayOwnerInformation(
|
||||
userHex: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadUser(baseUserHex = userHex, accountViewModel) {
|
||||
CrossfadeIfEnabled(it, accountViewModel = accountViewModel) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user