mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-07 07:05:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbb036da4a | ||
|
|
9a0c998654 | ||
|
|
88082b1ba3 | ||
|
|
ad2b2e6d02 | ||
|
|
5044a687fa | ||
|
|
298d4bb237 | ||
|
|
9c180cb87a | ||
|
|
2e3f2044d5 | ||
|
|
bdf012f641 | ||
|
|
034ee46543 | ||
|
|
6a17f94cb1 | ||
|
|
233059d544 | ||
|
|
7bd9b6a1ec | ||
|
|
04e77daaab | ||
|
|
308048c352 | ||
|
|
3f1173a42a | ||
|
|
39726142ae | ||
|
|
025cbeed07 | ||
|
|
8a1550cbd9 | ||
|
|
49148ddc76 | ||
|
|
d0e870e1b8 | ||
|
|
dbb2bfa512 | ||
|
|
d6b66c65fa | ||
|
|
9a3d365243 | ||
|
|
3b30d5493d | ||
|
|
7bfd1a9107 | ||
|
|
2c9e2de524 | ||
|
|
c89c5eb4b0 | ||
|
|
5c5eb53395 | ||
|
|
55f0092e03 | ||
|
|
54d9ac5730 | ||
|
|
d9a9137d89 | ||
|
|
7beff8c835 | ||
|
|
4a80a16473 | ||
|
|
b3e3b70dd0 | ||
|
|
03662b9d1b | ||
|
|
d33421fce4 | ||
|
|
4db6f5531c | ||
|
|
72ea4a44a2 | ||
|
|
022a9f6f6c | ||
|
|
248848799c | ||
|
|
72f317717b | ||
|
|
4651074774 | ||
|
|
fb058f93b6 | ||
|
|
9e34e9d720 | ||
|
|
04d6da28af | ||
|
|
192a43dc21 | ||
|
|
61510ae7e3 | ||
|
|
488286ce20 | ||
|
|
fab6c65e98 | ||
|
|
accde8622c | ||
|
|
4a590d67a6 | ||
|
|
6c3200a9cc | ||
|
|
f16143bec3 | ||
|
|
8216476916 | ||
|
|
ffd43e448b | ||
|
|
3a566dad6b | ||
|
|
bf861d6bc5 | ||
|
|
b31ace79c3 | ||
|
|
37a92c25f0 | ||
|
|
062e4af118 | ||
|
|
c083295f6a | ||
|
|
924baf0de3 | ||
|
|
6015c968f5 | ||
|
|
3e85991f19 | ||
|
|
893b23e4fb | ||
|
|
cb5d69e358 | ||
|
|
43688c4117 | ||
|
|
4e4521ecef | ||
|
|
7bc9205758 | ||
|
|
abf6cc00ae | ||
|
|
7146838f63 | ||
|
|
1037ab33e3 | ||
|
|
230fd553ae | ||
|
|
eac078c42d | ||
|
|
39c9b75bf4 | ||
|
|
e076a2dbe3 | ||
|
|
5705a26836 | ||
|
|
740c279825 | ||
|
|
718605e44b | ||
|
|
8df2ff839c | ||
|
|
00be9fc595 | ||
|
|
7d25ab13a9 | ||
|
|
d7888096e9 | ||
|
|
aad5aaa930 | ||
|
|
b877e4105a | ||
|
|
d1cd15c389 | ||
|
|
69e139e382 | ||
|
|
b2628764c9 | ||
|
|
f7f0d74be1 | ||
|
|
b47122d79a | ||
|
|
635ad30de9 | ||
|
|
0884318a55 | ||
|
|
d8c89317d2 | ||
|
|
772987dffd | ||
|
|
61c51ee9a8 | ||
|
|
f157f8eb9b | ||
|
|
d96f5953d5 | ||
|
|
ffb5cce509 | ||
|
|
b441292413 | ||
|
|
91748d5c39 | ||
|
|
a825f575de | ||
|
|
0581a3e836 | ||
|
|
82f0ed8a0d | ||
|
|
116bc108c3 | ||
|
|
44868ac4e6 | ||
|
|
2a24ca0787 | ||
|
|
03225fc696 | ||
|
|
a0ab4a824b | ||
|
|
0ba4ca13bf | ||
|
|
060a242a0f | ||
|
|
ae530ed813 | ||
|
|
a5c4a53afe | ||
|
|
fb6137f99a | ||
|
|
de99c50639 | ||
|
|
d5f8994128 | ||
|
|
cf02d808d6 | ||
|
|
f6aa732558 | ||
|
|
d321df79e8 | ||
|
|
d1b4f81083 | ||
|
|
0205f1fd12 | ||
|
|
d739fdac2d | ||
|
|
4a43a87a79 | ||
|
|
6f31898cdc | ||
|
|
6ebc878312 | ||
|
|
518e1c88ce | ||
|
|
f88985b2bf | ||
|
|
89e1e20b40 | ||
|
|
c2e725ef5f | ||
|
|
6e8c348cbc | ||
|
|
84316d320d | ||
|
|
87fb63af06 | ||
|
|
a3b0436d91 | ||
|
|
bb430605bc | ||
|
|
4c0d92f89f | ||
|
|
f0b14edef6 | ||
|
|
aae2ffebd7 | ||
|
|
7b11d0ce81 | ||
|
|
26ba75c7c9 | ||
|
|
67fb3bf488 | ||
|
|
24b29396cb | ||
|
|
25c3532637 | ||
|
|
cff6ee457d | ||
|
|
c0ba6f5b00 | ||
|
|
44d8b8ff33 | ||
|
|
f495acb570 | ||
|
|
ecfc8bcd87 | ||
|
|
724bea6bbb | ||
|
|
6826db13a4 | ||
|
|
c5dd2c5651 | ||
|
|
236ff2d680 | ||
|
|
13e2546d36 | ||
|
|
549b9f53e5 | ||
|
|
974c022aed | ||
|
|
a57566dd84 | ||
|
|
72e86cf391 | ||
|
|
3b23cded56 | ||
|
|
121337eb83 | ||
|
|
fe87d642c9 | ||
|
|
b8112ff1aa | ||
|
|
1eee0bd1ae | ||
|
|
f48335e3be | ||
|
|
fcc4befb22 | ||
|
|
e28a1896ec | ||
|
|
f10e66e6a5 | ||
|
|
825a227e2d | ||
|
|
3446fdaf7f | ||
|
|
9c29bf3f25 | ||
|
|
9642e45775 | ||
|
|
58788d2cd5 | ||
|
|
d7c18341cd | ||
|
|
ee16f6c143 | ||
|
|
d78b55d134 | ||
|
|
ff5e7359e2 | ||
|
|
4fd0f38402 | ||
|
|
5b516f4101 | ||
|
|
1fc390c215 | ||
|
|
2bb09f583b | ||
|
|
89de272ea1 |
@@ -30,4 +30,6 @@ If applicable, add a video and/or screenshots to help explain your problem.
|
||||
- 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.
|
||||
The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
|
||||
not even a small one, this bug will not be worked on because it doesn't matter to you. We prioritize
|
||||
bug fixing to issues that have bounties, even small ones.
|
||||
|
||||
@@ -7,14 +7,10 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Bounty (in Bitcoin sats) offered for the implementation**
|
||||
Incentivize developers to work on your feature. Describe clear milestones to claim payment.
|
||||
|
||||
**Additional context**
|
||||
Add any other context, video, or screenshots about the feature request here.
|
||||
The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
|
||||
not even a small one, this feature will not be coded because it doesn't actually matter to you.
|
||||
We prioritize feature development by its bounty size compared to how much work is required to get it done.
|
||||
@@ -42,15 +42,30 @@ jobs:
|
||||
- name: Build APK (gradle)
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
||||
- name: Upload APK
|
||||
- name: Upload Play APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Debug APK
|
||||
path: amethyst/build/outputs/apk/debug/app-debug.apk
|
||||
name: Play Debug APK
|
||||
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
|
||||
|
||||
- name: Upload FDroid APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FDroid Debug APK
|
||||
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
|
||||
|
||||
- name: Upload Compose Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Compose Reports
|
||||
path: amethyst/build/compose_compiler
|
||||
|
||||
- name: Tests
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build Reports
|
||||
name: Test Reports
|
||||
path: amethyst/build/reports
|
||||
|
||||
|
||||
+4
@@ -37,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" />
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.0.0" />
|
||||
<option name="version" value="2.0.20" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -20,6 +20,9 @@ Join the social network you control.
|
||||
|
||||
## Download and Install
|
||||
|
||||
[<img src="./docs/design/zapstore.svg"
|
||||
alt="Get it on Zap Store"
|
||||
height="70">](https://github.com/zapstore/zapstore/releases)
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
height="70">](https://github.com/ImranR98/Obtainium)
|
||||
@@ -105,7 +108,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Video Events (NIP-71)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [ ] Zap Goals (NIP-75)
|
||||
- [ ] Arbitrary Custom App Data (NIP-78)
|
||||
- [x] Arbitrary Custom App Data (NIP-78)
|
||||
- [x] Highlights (NIP-84)
|
||||
- [x] Notify Request (NIP-88/Draft)
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
|
||||
@@ -16,9 +16,9 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk libs.versions.android.minSdk.get().toInteger()
|
||||
targetSdk libs.versions.android.targetSdk.get().toInteger()
|
||||
versionCode 407
|
||||
versionName "0.92.2"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"98f2acaf9b3eb70d87607942e576f9a086ac28adf37869da9a29f4815cecac86\""
|
||||
versionCode 413
|
||||
versionName "0.93.0"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"f9e228d0579b0256044b54a78037f06edfa06c24862c368ad7e02b5caa7bd309\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -230,6 +230,8 @@ dependencies {
|
||||
implementation libs.coil.gif
|
||||
// view svgs
|
||||
implementation libs.coil.svg
|
||||
// enables network for coil
|
||||
implementation libs.coil.okhttp
|
||||
|
||||
// create blurhash
|
||||
implementation libs.trbl.blurhash
|
||||
|
||||
@@ -26,12 +26,17 @@ 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.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -47,7 +52,69 @@ import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ImageUploadTesting {
|
||||
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
|
||||
val account =
|
||||
Account(
|
||||
AccountSettings(KeyPair()),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
)
|
||||
|
||||
private suspend fun getBitmap(): ByteArray {
|
||||
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
|
||||
for (x in 0 until bitmap.width) {
|
||||
for (y in 0 until bitmap.height) {
|
||||
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
|
||||
}
|
||||
}
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
|
||||
return baos.toByteArray()
|
||||
}
|
||||
|
||||
private suspend fun testBase(server: ServerName) {
|
||||
if (server.type == ServerType.NIP96) {
|
||||
testNip96(server)
|
||||
} else {
|
||||
testBlossom(server)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun testBlossom(server: ServerName) {
|
||||
val paylod = getBitmap()
|
||||
val initialHash = CryptoUtils.sha256(paylod).toHexKey()
|
||||
val inputStream = paylod.inputStream()
|
||||
val result =
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
inputStream,
|
||||
initialHash,
|
||||
paylod.size,
|
||||
"filename.png",
|
||||
"image/png",
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server,
|
||||
forceProxy = { false },
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
)
|
||||
|
||||
assertEquals("image/png", result.type)
|
||||
assertEquals(paylod.size.toLong(), result.size)
|
||||
assertEquals(initialHash, result.sha256)
|
||||
assertEquals("${server.baseUrl}/$initialHash", result.url)
|
||||
|
||||
val imageData: ByteArray =
|
||||
ImageDownloader().waitAndGetImage(result.url!!, false)
|
||||
?: run {
|
||||
fail("${server.name}: Should not be null")
|
||||
return
|
||||
}
|
||||
|
||||
val downloadedHash = CryptoUtils.sha256(imageData).toHexKey()
|
||||
assertEquals(initialHash, downloadedHash)
|
||||
}
|
||||
|
||||
private suspend fun testNip96(server: ServerName) {
|
||||
val serverInfo =
|
||||
Nip96Retriever()
|
||||
.loadInfo(
|
||||
@@ -55,28 +122,13 @@ class ImageUploadTesting {
|
||||
false,
|
||||
)
|
||||
|
||||
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
|
||||
for (x in 0 until bitmap.width) {
|
||||
for (y in 0 until bitmap.height) {
|
||||
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
|
||||
}
|
||||
}
|
||||
val baos = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
|
||||
val bytes = baos.toByteArray()
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
val account =
|
||||
Account(
|
||||
AccountSettings(KeyPair()),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
)
|
||||
|
||||
val paylod = getBitmap()
|
||||
val inputStream = paylod.inputStream()
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
paylod.size.toLong(),
|
||||
"image/png",
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
@@ -140,7 +192,7 @@ class ImageUploadTesting {
|
||||
@Test
|
||||
fun runTestOnDefaultServers() =
|
||||
runBlocking {
|
||||
Nip96MediaServers.DEFAULT.forEach {
|
||||
DEFAULT_MEDIA_SERVERS.forEach {
|
||||
testBase(it)
|
||||
}
|
||||
}
|
||||
@@ -148,58 +200,76 @@ class ImageUploadTesting {
|
||||
@Test()
|
||||
fun testNostrCheck() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
|
||||
testBase(ServerName("nostrcheck.me", "https://nostrcheck.me", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrage() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
|
||||
testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSove() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
|
||||
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrBuild() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
|
||||
testBase(ServerName("nostr.build", "https://nostr.build", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSovbit() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
|
||||
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testVoidCat() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
|
||||
testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrPic() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
|
||||
testBase(ServerName("nostpic.com", "https://nostpic.com", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test(expected = RuntimeException::class)
|
||||
fun testSprovoostNl() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/"))
|
||||
testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrOnch() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services"))
|
||||
testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Ignore("Changes sha256")
|
||||
fun testPrimalBlossom() =
|
||||
runBlocking {
|
||||
testBase(ServerName("primal.net", "https://blossom.primal.net", ServerType.Blossom))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrCheckBlossom() =
|
||||
runBlocking {
|
||||
testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom))
|
||||
}
|
||||
|
||||
@Ignore("Requires Payment")
|
||||
fun testSatelliteBlossom() =
|
||||
runBlocking {
|
||||
testBase(ServerName("satellite", "https://cdn.satellite.earth", ServerType.Blossom))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslationsTest {
|
||||
@@ -124,7 +125,10 @@ class TranslationsTest {
|
||||
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = CancellationException::class)
|
||||
fun testTranslationLnInvoice2() {
|
||||
assertTranslateContains(
|
||||
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
@@ -132,7 +136,7 @@ class TranslationsTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Test(expected = CancellationException::class)
|
||||
fun testNostrEvents() {
|
||||
assertTranslateContains(
|
||||
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
@@ -162,4 +166,15 @@ class TranslationsTest {
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHttp() {
|
||||
val text = "https://m.primal.net/MdDd.png \nRunning... \uD83D\uDE01 nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm"
|
||||
|
||||
assertTranslateContains(
|
||||
"https://m.primal.net/MdDd.png",
|
||||
text,
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,8 @@
|
||||
<!-- 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"/>
|
||||
|
||||
<!-- To Upload media (old devices) -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
<!-- To read QRs that contain nostr:<NIP19> -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
@@ -103,9 +99,23 @@
|
||||
<data android:scheme="nostr+walletconnect" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
android:value="" />
|
||||
<intent-filter android:label="New Post">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="New Post">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="New Post">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
|
||||
@@ -29,9 +29,12 @@ import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import coil3.ImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.crossfade
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -42,6 +45,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -50,6 +54,7 @@ class Amethyst : Application() {
|
||||
|
||||
// Service Manager is only active when the activity is active.
|
||||
val serviceManager = ServiceManager(applicationIOScope)
|
||||
val locationManager = LocationState(this, applicationIOScope)
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
@@ -72,15 +77,16 @@ class Amethyst : Application() {
|
||||
val coilCache: DiskCache by lazy {
|
||||
DiskCache
|
||||
.Builder()
|
||||
.directory(safeCacheDir.resolve("image_cache"))
|
||||
.directory(safeCacheDir.resolve("image_cache").toOkioPath())
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
|
||||
.build()
|
||||
}
|
||||
|
||||
val coilMemCache: MemoryCache by lazy {
|
||||
val memoryCache: MemoryCache by lazy {
|
||||
MemoryCache
|
||||
.Builder(this)
|
||||
.Builder()
|
||||
.maxSizePercent(this)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -126,7 +132,7 @@ class Amethyst : Application() {
|
||||
ImageLoader
|
||||
.Builder(this)
|
||||
.diskCache { coilCache }
|
||||
.memoryCache { coilMemCache }
|
||||
.memoryCache { memoryCache }
|
||||
.crossfade(true)
|
||||
|
||||
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
|
||||
@@ -140,7 +146,10 @@ class Amethyst : Application() {
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() }
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
println("Trim Memory Inside $level")
|
||||
serviceManager.trimMemory()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -26,7 +26,6 @@ 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
|
||||
@@ -92,14 +91,13 @@ fun debugState(context: Context) {
|
||||
|
||||
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",
|
||||
"Image Disk Cache ${(Amethyst.instance.coilCache.size) / (1024 * 1024)}/${(Amethyst.instance.coilCache.maxSize) / (1024 * 1024)} MB",
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB",
|
||||
"Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.size) / (1024 * 1024)} MB",
|
||||
)
|
||||
|
||||
Log.d(
|
||||
|
||||
@@ -26,14 +26,20 @@ 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.AccountLanguagePreferencesInternal
|
||||
import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal
|
||||
import com.vitorpamplona.amethyst.model.AccountSecurityPreferencesInternal
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
|
||||
import com.vitorpamplona.amethyst.model.AccountZapPreferencesInternal
|
||||
import com.vitorpamplona.amethyst.model.DefaultReactions
|
||||
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
@@ -45,6 +51,7 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
@@ -53,11 +60,9 @@ 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
|
||||
@@ -104,6 +109,7 @@ private object PrefKeys {
|
||||
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
|
||||
const val LATEST_MUTE_LIST = "latestMuteList"
|
||||
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
|
||||
const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData"
|
||||
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
|
||||
@@ -310,19 +316,7 @@ object LocalPreferences {
|
||||
}
|
||||
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.LANGUAGE_PREFS,
|
||||
Event.mapper.writeValueAsString(settings.languagePreferences),
|
||||
)
|
||||
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
|
||||
putString(
|
||||
PrefKeys.REACTION_CHOICES,
|
||||
Event.mapper.writeValueAsString(settings.reactionChoices.value),
|
||||
)
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
|
||||
|
||||
putString(
|
||||
PrefKeys.DEFAULT_FILE_SERVER,
|
||||
Event.mapper.writeValueAsString(settings.defaultFileServer),
|
||||
@@ -404,6 +398,15 @@ object LocalPreferences {
|
||||
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupAppSpecificData != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_APP_SPECIFIC_DATA,
|
||||
Event.mapper.writeValueAsString(settings.backupAppSpecificData),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -414,9 +417,6 @@ object LocalPreferences {
|
||||
|
||||
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
|
||||
@@ -428,12 +428,6 @@ object LocalPreferences {
|
||||
)
|
||||
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),
|
||||
@@ -441,6 +435,7 @@ object LocalPreferences {
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
Log.d("LocalPreferences", "Saved to encrypted storage")
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
@@ -510,9 +505,6 @@ object LocalPreferences {
|
||||
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()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList =
|
||||
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList =
|
||||
@@ -528,13 +520,12 @@ object LocalPreferences {
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
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 defaultFileServer = parseOrNull<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0]
|
||||
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
|
||||
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
||||
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
|
||||
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
|
||||
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
|
||||
@@ -543,6 +534,54 @@ object LocalPreferences {
|
||||
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 latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
|
||||
|
||||
val syncedSettings =
|
||||
if (latestAppSpecificData != null) {
|
||||
null
|
||||
} else {
|
||||
// previous version. Delete this when ready.
|
||||
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
|
||||
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
|
||||
|
||||
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
|
||||
|
||||
val showSensitiveContent =
|
||||
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
|
||||
AccountSyncedSettingsInternal(
|
||||
reactions =
|
||||
AccountReactionPreferencesInternal(
|
||||
reactionChoices = reactionChoices,
|
||||
),
|
||||
zaps =
|
||||
AccountZapPreferencesInternal(
|
||||
zapAmountChoices = zapAmountChoices,
|
||||
defaultZapType = defaultZapType,
|
||||
),
|
||||
languages =
|
||||
AccountLanguagePreferencesInternal(
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
),
|
||||
security =
|
||||
AccountSecurityPreferencesInternal(
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
@@ -571,15 +610,6 @@ object LocalPreferences {
|
||||
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
|
||||
}
|
||||
|
||||
val showSensitiveContent =
|
||||
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val lastReadPerRoute =
|
||||
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
|
||||
MutableStateFlow(it.value)
|
||||
@@ -594,12 +624,6 @@ object LocalPreferences {
|
||||
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),
|
||||
@@ -616,10 +640,9 @@ object LocalPreferences {
|
||||
backupSearchRelayList = latestSearchRelayList,
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
|
||||
backupMuteList = latestMuteList,
|
||||
backupAppSpecificData = latestAppSpecificData,
|
||||
backupSyncedSettings = syncedSettings,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
showSensitiveContent = MutableStateFlow(showSensitiveContent),
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Launches an async coroutine for each item, runs the
|
||||
* function and waits for everybody to finsih
|
||||
*/
|
||||
suspend fun <T> launchAndWaitAll(
|
||||
items: List<T>,
|
||||
asyncFunc: suspend (T) -> Unit,
|
||||
) {
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
items.map { next ->
|
||||
async {
|
||||
asyncFunc(next)
|
||||
}
|
||||
}
|
||||
|
||||
// runs in parallel to avoid overcrowding Amber.
|
||||
withTimeoutOrNull(15000) {
|
||||
jobs.joinAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the function and waits for 10 seconds for any result.
|
||||
*/
|
||||
suspend inline fun <T> tryAndWait(crossinline asyncFunc: (CancellableContinuation<T>) -> Unit): T? =
|
||||
withTimeoutOrNull(10000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
asyncFunc(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an async coroutine for each one of the items,
|
||||
* runs the request for that item,
|
||||
* and gathers all the results in the output map.
|
||||
*/
|
||||
suspend fun <T, K> collectSuccessfulOperations(
|
||||
items: List<T>,
|
||||
runRequestFor: (T, (K) -> Unit) -> Unit,
|
||||
output: MutableMap<T, K> = mutableMapOf(),
|
||||
onReady: suspend (MutableMap<T, K>) -> Unit,
|
||||
) {
|
||||
if (items.isEmpty()) {
|
||||
onReady(output)
|
||||
return
|
||||
}
|
||||
|
||||
launchAndWaitAll(items) {
|
||||
val result =
|
||||
tryAndWait { continuation ->
|
||||
runRequestFor(it) { result: K -> continuation.resume(result) }
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
output[it] = result
|
||||
}
|
||||
}
|
||||
|
||||
onReady(output)
|
||||
}
|
||||
@@ -23,15 +23,17 @@ package com.vitorpamplona.amethyst
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import coil.Coil
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.size.Precision
|
||||
import coil.util.DebugLogger
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.size.Precision
|
||||
import coil3.svg.SvgDecoder
|
||||
import coil3.util.DebugLogger
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.Base64Fetcher
|
||||
import com.vitorpamplona.amethyst.service.BlurHashFetcher
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
@@ -115,27 +117,40 @@ class ServiceManager(
|
||||
HttpClientManager.setDefaultProxy(null)
|
||||
}
|
||||
|
||||
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
|
||||
Coil.setImageLoader {
|
||||
// Convert this into a flow
|
||||
LocalCache.antiSpam.active = account
|
||||
?.settings
|
||||
?.syncedSettings
|
||||
?.security
|
||||
?.filterSpamFromStrangers ?: true
|
||||
|
||||
SingletonImageLoader.setSafe {
|
||||
Amethyst.instance
|
||||
.imageLoaderBuilder()
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
add(AnimatedImageDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
add(Base64Fetcher.Factory)
|
||||
add(BlurHashFetcher.Factory)
|
||||
add(Base64Fetcher.BKeyer)
|
||||
add(BlurHashFetcher.BKeyer)
|
||||
add(
|
||||
OkHttpNetworkFetcherFactory(
|
||||
callFactory = {
|
||||
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
|
||||
?: HttpClientManager.getHttpClient(false)
|
||||
},
|
||||
),
|
||||
)
|
||||
}.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()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.ammolite.relays.Constants
|
||||
@@ -34,6 +35,7 @@ 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.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -44,8 +46,6 @@ 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
|
||||
@@ -61,17 +61,6 @@ val DefaultChannels =
|
||||
"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),
|
||||
@@ -81,9 +70,9 @@ val DefaultNIP65List =
|
||||
|
||||
val DefaultDMRelayList =
|
||||
listOf(
|
||||
RelayUrlFormatter.normalize("wss://auth.nostr1.com/"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.mom/"),
|
||||
RelayUrlFormatter.normalize("wss://nos.lol/"),
|
||||
RelayUrlFormatter.normalize("wss://auth.nostr1.com"),
|
||||
RelayUrlFormatter.normalize("wss://relay.0xchat.com"),
|
||||
RelayUrlFormatter.normalize("wss://nos.lol"),
|
||||
)
|
||||
|
||||
val DefaultSearchRelayList =
|
||||
@@ -91,25 +80,18 @@ val DefaultSearchRelayList =
|
||||
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.wine"),
|
||||
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
|
||||
RelayUrlFormatter.normalize("wss://search.nos.today"),
|
||||
)
|
||||
|
||||
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 "
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val AROUND_ME = " Around Me "
|
||||
|
||||
@Stable
|
||||
class AccountSettings(
|
||||
val keyPair: KeyPair,
|
||||
@@ -117,13 +99,7 @@ class AccountSettings(
|
||||
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],
|
||||
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
|
||||
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
|
||||
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
@@ -139,15 +115,17 @@ class AccountSettings(
|
||||
var backupSearchRelayList: SearchRelayListEvent? = null,
|
||||
var backupMuteList: MuteListEvent? = null,
|
||||
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
|
||||
var backupAppSpecificData: AppSpecificDataEvent? = null,
|
||||
backupSyncedSettings: AccountSyncedSettingsInternal? = null, // only exist for migration purposes
|
||||
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))
|
||||
val syncedSettings: AccountSyncedSettings =
|
||||
backupSyncedSettings?.let { AccountSyncedSettings(it) }
|
||||
?: AccountSyncedSettings(AccountSyncedSettingsInternal())
|
||||
|
||||
class AccountSettingsUpdater(
|
||||
val accountSettings: AccountSettings,
|
||||
@@ -165,7 +143,19 @@ class AccountSettings(
|
||||
} else {
|
||||
when (val packageName = externalSignerPackageName) {
|
||||
null -> NostrSignerInternal(keyPair)
|
||||
else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName))
|
||||
else -> {
|
||||
val externalSignerLauncher = ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName)
|
||||
// TODO: How to handle the launcher here?
|
||||
try {
|
||||
externalSignerLauncher.registerLauncher(
|
||||
launcher = { },
|
||||
contentResolver = Amethyst.instance::contentResolverFn,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.d("AccountSettings", "Failed to initialize external signer", e)
|
||||
}
|
||||
NostrSignerExternal(keyPair.pubKey.toHexKey(), externalSignerLauncher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,39 +163,47 @@ class AccountSettings(
|
||||
// Zaps and Reactions
|
||||
// ---
|
||||
|
||||
fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
|
||||
if (defaultZapType.value != zapType) {
|
||||
defaultZapType.tryEmit(zapType)
|
||||
fun changeDefaultZapType(zapType: LnZapEvent.ZapType): Boolean {
|
||||
if (syncedSettings.zaps.defaultZapType.value != zapType) {
|
||||
syncedSettings.zaps.defaultZapType.tryEmit(zapType)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
if (zapAmountChoices.value != newAmounts) {
|
||||
zapAmountChoices.tryEmit(newAmounts.toImmutableList())
|
||||
fun changeZapAmounts(newAmounts: List<Long>): Boolean {
|
||||
if (syncedSettings.zaps.zapAmountChoices.value != newAmounts) {
|
||||
syncedSettings.zaps.zapAmountChoices.tryEmit(newAmounts.toImmutableList())
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
|
||||
fun changeReactionTypes(newTypes: List<String>): Boolean {
|
||||
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
|
||||
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?): Boolean {
|
||||
if (zapPaymentRequest != newServer) {
|
||||
zapPaymentRequest = newServer
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun changeReactionTypes(newTypes: List<String>) {
|
||||
if (reactionChoices.value != newTypes) {
|
||||
reactionChoices.tryEmit(newTypes.toImmutableList())
|
||||
saveAccountSettings()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---
|
||||
// file servers
|
||||
// ---
|
||||
|
||||
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
|
||||
fun changeDefaultFileServer(server: ServerName) {
|
||||
if (defaultFileServer != server) {
|
||||
defaultFileServer = server
|
||||
saveAccountSettings()
|
||||
@@ -260,22 +258,18 @@ class AccountSettings(
|
||||
// language services
|
||||
// ---
|
||||
fun toggleDontTranslateFrom(languageCode: String) {
|
||||
if (!dontTranslateFrom.contains(languageCode)) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
saveAccountSettings()
|
||||
} else {
|
||||
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
syncedSettings.languages.toggleDontTranslateFrom(languageCode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
|
||||
fun translateToContains(languageCode: Locale) = syncedSettings.languages.translateTo.contains(languageCode.language)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale) {
|
||||
if (translateTo != languageCode.language) {
|
||||
translateTo = languageCode.language
|
||||
fun updateTranslateTo(languageCode: Locale): Boolean {
|
||||
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun prefer(
|
||||
@@ -283,23 +277,14 @@ class AccountSettings(
|
||||
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)
|
||||
}
|
||||
}
|
||||
syncedSettings.languages.prefer(source, target, preference)
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun preferenceBetween(
|
||||
source: String,
|
||||
target: String,
|
||||
): String? = languagePreferences["$source,$target"]
|
||||
): String? = syncedSettings.languages.preferenceBetween(source, target)
|
||||
|
||||
// ----
|
||||
// Backup Lists
|
||||
@@ -382,6 +367,21 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAppSpecificData(
|
||||
appSettings: AppSpecificDataEvent?,
|
||||
newSyncedSettings: AccountSyncedSettingsInternal,
|
||||
) {
|
||||
if (appSettings == null || appSettings.content().isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupAppSpecificData?.id != appSettings.id) {
|
||||
backupAppSpecificData = appSettings
|
||||
syncedSettings.updateFrom(newSyncedSettings)
|
||||
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// Warning dialogs
|
||||
// ----
|
||||
@@ -407,15 +407,6 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
showSensitiveContent.update { show }
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---
|
||||
// donations
|
||||
// ---
|
||||
@@ -509,16 +500,20 @@ class AccountSettings(
|
||||
// ---
|
||||
// filters
|
||||
// ---
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (syncedSettings.security.updateShowSensitiveContent(show)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun updateOptOutOptions(
|
||||
warnReports: Boolean,
|
||||
filterSpam: Boolean,
|
||||
): Boolean =
|
||||
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
|
||||
if (syncedSettings.security.updateOptOutOptions(warnReports, filterSpam)) {
|
||||
saveAccountSettings()
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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 androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
|
||||
@Stable
|
||||
class AccountSyncedSettings(
|
||||
internalSettings: AccountSyncedSettingsInternal,
|
||||
) {
|
||||
val reactions = AccountReactionPreferences(MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()))
|
||||
val zaps =
|
||||
AccountZapPreferences(
|
||||
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
|
||||
MutableStateFlow(internalSettings.zaps.defaultZapType),
|
||||
)
|
||||
val languages =
|
||||
AccountLanguagePreferences(
|
||||
internalSettings.languages.dontTranslateFrom,
|
||||
internalSettings.languages.languagePreferences,
|
||||
internalSettings.languages.translateTo,
|
||||
)
|
||||
val security =
|
||||
AccountSecurityPreferences(
|
||||
MutableStateFlow(internalSettings.security.showSensitiveContent),
|
||||
internalSettings.security.warnAboutPostsWithReports,
|
||||
internalSettings.security.filterSpamFromStrangers,
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
AccountSyncedSettingsInternal(
|
||||
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value),
|
||||
zaps =
|
||||
AccountZapPreferencesInternal(
|
||||
zaps.zapAmountChoices.value,
|
||||
zaps.defaultZapType.value,
|
||||
),
|
||||
languages =
|
||||
AccountLanguagePreferencesInternal(
|
||||
languages.dontTranslateFrom,
|
||||
languages.languagePreferences,
|
||||
languages.translateTo,
|
||||
),
|
||||
security =
|
||||
AccountSecurityPreferencesInternal(
|
||||
security.showSensitiveContent.value,
|
||||
security.warnAboutPostsWithReports,
|
||||
security.filterSpamFromStrangers,
|
||||
),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
val newReactionChoices = syncedSettingsInternal.reactions.reactionChoices.toImmutableList()
|
||||
if (!equalImmutableLists(reactions.reactionChoices.value, newReactionChoices)) {
|
||||
reactions.reactionChoices.tryEmit(newReactionChoices)
|
||||
}
|
||||
|
||||
val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList()
|
||||
if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) {
|
||||
zaps.zapAmountChoices.tryEmit(newZapChoices)
|
||||
}
|
||||
|
||||
if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) {
|
||||
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
|
||||
}
|
||||
|
||||
if (languages.dontTranslateFrom != syncedSettingsInternal.languages.dontTranslateFrom) {
|
||||
languages.dontTranslateFrom = syncedSettingsInternal.languages.dontTranslateFrom
|
||||
}
|
||||
|
||||
if (languages.languagePreferences != syncedSettingsInternal.languages.languagePreferences) {
|
||||
languages.languagePreferences = syncedSettingsInternal.languages.languagePreferences
|
||||
}
|
||||
|
||||
if (languages.translateTo != syncedSettingsInternal.languages.translateTo) {
|
||||
languages.translateTo = syncedSettingsInternal.languages.translateTo
|
||||
}
|
||||
|
||||
if (security.showSensitiveContent.value != syncedSettingsInternal.security.showSensitiveContent) {
|
||||
security.showSensitiveContent.tryEmit(syncedSettingsInternal.security.showSensitiveContent)
|
||||
}
|
||||
|
||||
if (security.filterSpamFromStrangers != syncedSettingsInternal.security.filterSpamFromStrangers) {
|
||||
security.filterSpamFromStrangers = syncedSettingsInternal.security.filterSpamFromStrangers
|
||||
}
|
||||
|
||||
if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) {
|
||||
security.warnAboutPostsWithReports = syncedSettingsInternal.security.warnAboutPostsWithReports
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountReactionPreferences(
|
||||
var reactionChoices: MutableStateFlow<ImmutableList<String>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class AccountZapPreferences(
|
||||
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
|
||||
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class AccountLanguagePreferences(
|
||||
var dontTranslateFrom: Set<String>,
|
||||
var languagePreferences: Map<String, String>,
|
||||
var translateTo: String,
|
||||
) {
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
fun toggleDontTranslateFrom(languageCode: String) {
|
||||
if (!dontTranslateFrom.contains(languageCode)) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
} else {
|
||||
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale): Boolean {
|
||||
if (translateTo != languageCode.language) {
|
||||
translateTo = languageCode.language
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun prefer(
|
||||
source: String,
|
||||
target: String,
|
||||
preference: String,
|
||||
) {
|
||||
val key = "$source,$target"
|
||||
if (key !in languagePreferences) {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
} 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"]
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountSecurityPreferences(
|
||||
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
) {
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
showSensitiveContent.update { show }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---
|
||||
// filters
|
||||
// ---
|
||||
fun updateOptOutOptions(
|
||||
warnReports: Boolean,
|
||||
filterSpam: Boolean,
|
||||
): Boolean =
|
||||
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
+76
@@ -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.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultReactions =
|
||||
listOf(
|
||||
"\uD83D\uDE80",
|
||||
"\uD83E\uDEC2",
|
||||
"\uD83D\uDC40",
|
||||
"\uD83D\uDE02",
|
||||
"\uD83C\uDF89",
|
||||
"\uD83E\uDD14",
|
||||
"\uD83D\uDE31",
|
||||
)
|
||||
|
||||
val DefaultZapAmounts = listOf(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
|
||||
}
|
||||
|
||||
class AccountSyncedSettingsInternal(
|
||||
val reactions: AccountReactionPreferencesInternal = AccountReactionPreferencesInternal(),
|
||||
val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(),
|
||||
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
|
||||
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
|
||||
)
|
||||
|
||||
class AccountReactionPreferencesInternal(
|
||||
var reactionChoices: List<String> = DefaultReactions,
|
||||
)
|
||||
|
||||
class AccountZapPreferencesInternal(
|
||||
var zapAmountChoices: List<Long> = DefaultZapAmounts,
|
||||
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
)
|
||||
|
||||
class AccountLanguagePreferencesInternal(
|
||||
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
)
|
||||
|
||||
class AccountSecurityPreferencesInternal(
|
||||
val showSensitiveContent: Boolean? = null,
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
)
|
||||
@@ -31,7 +31,7 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.encoders.toNEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -107,7 +107,7 @@ abstract class Channel(
|
||||
|
||||
open fun id() = Hex.decode(idHex)
|
||||
|
||||
open fun idNote() = id().toNote()
|
||||
open fun idNote() = id().toNEvent()
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
|
||||
@@ -40,12 +40,15 @@ import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarEvent
|
||||
@@ -61,6 +64,7 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
@@ -82,6 +86,9 @@ import com.vitorpamplona.quartz.events.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryReadingStateEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -99,6 +106,7 @@ import com.vitorpamplona.quartz.events.NIP90UserDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.events.NNSEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PictureEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
@@ -437,61 +445,30 @@ object LocalCache {
|
||||
fun consume(
|
||||
event: TextNoteEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
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)
|
||||
}
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: TorrentCommentEvent,
|
||||
event: InteractiveStoryPrologueEvent,
|
||||
relay: Relay?,
|
||||
) = consumeBaseReplaceable(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: InteractiveStorySceneEvent,
|
||||
relay: Relay?,
|
||||
) = consumeBaseReplaceable(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: InteractiveStoryReadingStateEvent,
|
||||
relay: Relay?,
|
||||
) = consumeBaseReplaceable(event, relay)
|
||||
|
||||
fun consumeRegularEvent(
|
||||
event: Event,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
@@ -505,242 +482,69 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
if (event is BaseTextNoteEvent && antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: PictureEvent,
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: TorrentCommentEvent,
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryResponseEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Log.d("TN", "New Response ${event.taggedEvents().joinToString(", ") { it }}}")
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryRequestEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: NIP90StatusEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: NIP90UserDiscoveryResponseEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: NIP90UserDiscoveryRequestEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: GitPatchEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: GitIssueEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: GitReplyEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
// println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}")
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: LongTextNoteEvent,
|
||||
@@ -817,6 +621,8 @@ object LocalCache {
|
||||
is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
is GitReplyEvent -> event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
|
||||
is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||
is LnZapEvent ->
|
||||
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
@@ -865,35 +671,8 @@ object LocalCache {
|
||||
|
||||
fun consume(
|
||||
event: PollNoteEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
private fun consume(
|
||||
event: LiveActivitiesEvent,
|
||||
@@ -953,6 +732,13 @@ object LocalCache {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: BlossomServersEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: FileServersEvent,
|
||||
relay: Relay?,
|
||||
@@ -1133,25 +919,10 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeAwardEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val awardDefinition = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, awardDefinition)
|
||||
|
||||
// Replies of an Badge Definition are Award Events
|
||||
awardDefinition.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
fun consume(
|
||||
event: BadgeAwardEvent,
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
private fun comsume(
|
||||
event: NNSEvent,
|
||||
@@ -1232,6 +1003,13 @@ object LocalCache {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: AppSpecificDataEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: RecommendRelayEvent) {
|
||||
// // Log.d("RR", event.toJson())
|
||||
@@ -1624,6 +1402,11 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: CommentEvent,
|
||||
relay: Relay?,
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: LiveActivitiesChatMessageEvent,
|
||||
relay: Relay?,
|
||||
@@ -1719,102 +1502,27 @@ object LocalCache {
|
||||
fun consume(
|
||||
event: AudioHeaderEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: FileHeaderEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: ProfileGalleryEntryEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: FileStorageHeaderEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: FhirResourceEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: TextNoteModificationEvent,
|
||||
@@ -1847,22 +1555,7 @@ object LocalCache {
|
||||
fun consume(
|
||||
event: HighlightEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
) = consumeRegularEvent(event, relay)
|
||||
|
||||
fun consume(
|
||||
event: FileStorageEvent,
|
||||
@@ -2035,7 +1728,10 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
fun findUsersStartingWith(
|
||||
username: String,
|
||||
forAccount: Account?,
|
||||
): List<User> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = decodePublicKeyAsHexOrNull(username)
|
||||
@@ -2048,13 +1744,19 @@ object LocalCache {
|
||||
}
|
||||
|
||||
return users.filter { _, user: User ->
|
||||
(user.anyNameStartsWith(username)) ||
|
||||
user.pubkeyHex.startsWith(username, true) ||
|
||||
user.pubkeyNpub().startsWith(username, true)
|
||||
(
|
||||
(user.anyNameStartsWith(username)) ||
|
||||
user.pubkeyHex.startsWith(username, true) ||
|
||||
user.pubkeyNpub().startsWith(username, true)
|
||||
) &&
|
||||
(forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.flowHiddenUsers.value.hiddenWordsCase)))
|
||||
}
|
||||
}
|
||||
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
fun findNotesStartingWith(
|
||||
text: String,
|
||||
forAccount: Account,
|
||||
): List<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = decodeEventIdAsHexOrNull(text)
|
||||
@@ -2078,14 +1780,21 @@ object LocalCache {
|
||||
}
|
||||
|
||||
if (note.event?.matchTag1With(text) == true ||
|
||||
note.idHex.startsWith(text, true) ||
|
||||
note.idNote().startsWith(text, true)
|
||||
note.idHex.startsWith(text, true)
|
||||
) {
|
||||
return@filter true
|
||||
if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) {
|
||||
return@filter true
|
||||
} else {
|
||||
return@filter false
|
||||
}
|
||||
}
|
||||
|
||||
if (note.event?.isContentEncoded() == false) {
|
||||
return@filter note.event?.content()?.contains(text, true) ?: false
|
||||
if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) {
|
||||
return@filter note.event?.content()?.contains(text, true) ?: false
|
||||
} else {
|
||||
return@filter false
|
||||
}
|
||||
}
|
||||
|
||||
return@filter false
|
||||
@@ -2104,11 +1813,19 @@ object LocalCache {
|
||||
if (addressable.event?.matchTag1With(text) == true ||
|
||||
addressable.idHex.startsWith(text, true)
|
||||
) {
|
||||
return@filter true
|
||||
if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) {
|
||||
return@filter true
|
||||
} else {
|
||||
return@filter false
|
||||
}
|
||||
}
|
||||
|
||||
if (addressable.event?.isContentEncoded() == false) {
|
||||
return@filter addressable.event?.content()?.contains(text, true) ?: false
|
||||
if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) {
|
||||
return@filter addressable.event?.content()?.contains(text, true) ?: false
|
||||
} else {
|
||||
return@filter false
|
||||
}
|
||||
}
|
||||
|
||||
return@filter false
|
||||
@@ -2645,11 +2362,13 @@ object LocalCache {
|
||||
is AdvertisedRelayListEvent -> consume(event, relay)
|
||||
is AppDefinitionEvent -> consume(event, relay)
|
||||
is AppRecommendationEvent -> consume(event, relay)
|
||||
is AppSpecificDataEvent -> consume(event, relay)
|
||||
is AudioHeaderEvent -> consume(event, relay)
|
||||
is AudioTrackEvent -> consume(event, relay)
|
||||
is BadgeAwardEvent -> consume(event)
|
||||
is BadgeAwardEvent -> consume(event, relay)
|
||||
is BadgeDefinitionEvent -> consume(event, relay)
|
||||
is BadgeProfilesEvent -> consume(event)
|
||||
is BlossomServersEvent -> consume(event, relay)
|
||||
is BookmarkListEvent -> consume(event)
|
||||
is CalendarEvent -> consume(event, relay)
|
||||
is CalendarDateSlotEvent -> consume(event, relay)
|
||||
@@ -2664,6 +2383,7 @@ object LocalCache {
|
||||
is ChatMessageEvent -> consume(event, relay)
|
||||
is ChatMessageRelayListEvent -> consume(event, relay)
|
||||
is ClassifiedsEvent -> consume(event, relay)
|
||||
is CommentEvent -> consume(event, relay)
|
||||
is CommunityDefinitionEvent -> consume(event, relay)
|
||||
is CommunityListEvent -> consume(event, relay)
|
||||
is CommunityPostApprovalEvent -> {
|
||||
@@ -2691,6 +2411,9 @@ object LocalCache {
|
||||
is GitPatchEvent -> consume(event, relay)
|
||||
is GitRepositoryEvent -> consume(event, relay)
|
||||
is HighlightEvent -> consume(event, relay)
|
||||
is InteractiveStoryPrologueEvent -> consume(event, relay)
|
||||
is InteractiveStorySceneEvent -> consume(event, relay)
|
||||
is InteractiveStoryReadingStateEvent -> consume(event, relay)
|
||||
is LiveActivitiesEvent -> consume(event, relay)
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
@@ -2713,6 +2436,7 @@ object LocalCache {
|
||||
is MuteListEvent -> consume(event, relay)
|
||||
is NNSEvent -> comsume(event, relay)
|
||||
is OtsEvent -> consume(event, relay)
|
||||
is PictureEvent -> consume(event, relay)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PrivateOutboxRelayListEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event, relay)
|
||||
|
||||
@@ -25,9 +25,11 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import com.vitorpamplona.amethyst.launchAndWaitAll
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
|
||||
import com.vitorpamplona.amethyst.tryAndWait
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.updated
|
||||
import com.vitorpamplona.amethyst.ui.note.combineWith
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
@@ -40,7 +42,6 @@ import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
@@ -65,13 +66,12 @@ import com.vitorpamplona.quartz.events.WrappedEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
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
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@@ -79,9 +79,9 @@ import kotlin.coroutines.resume
|
||||
class AddressableNote(
|
||||
val address: ATag,
|
||||
) : Note(address.toTag()) {
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun idNote() = address.toNAddr(relayHintUrl())
|
||||
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
override fun toNEvent() = address.toNAddr(relayHintUrl())
|
||||
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
@@ -96,7 +96,7 @@ class AddressableNote(
|
||||
return minOf(publishedAt, lastCreatedAt)
|
||||
}
|
||||
|
||||
fun dTag(): String? = (event as? AddressableEvent)?.dTag()
|
||||
fun dTag(): String = address.dTag
|
||||
|
||||
override fun wasOrShouldBeDeletedBy(
|
||||
deletionEvents: Set<HexKey>,
|
||||
@@ -145,7 +145,7 @@ open class Note(
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
|
||||
open fun idNote() = id().toNote()
|
||||
open fun idNote() = toNEvent()
|
||||
|
||||
open fun toNEvent(): String {
|
||||
val myEvent = event
|
||||
@@ -156,13 +156,27 @@ open class Note(
|
||||
host.id,
|
||||
host.pubKey,
|
||||
host.kind,
|
||||
relays.firstOrNull()?.url,
|
||||
relayHintUrl(),
|
||||
)
|
||||
} else {
|
||||
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl())
|
||||
}
|
||||
} else {
|
||||
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relayHintUrl())
|
||||
}
|
||||
}
|
||||
|
||||
fun relayHintUrl(): String? {
|
||||
val authorRelay = author?.latestMetadataRelay
|
||||
|
||||
return if (relays.isNotEmpty()) {
|
||||
if (authorRelay != null && relays.any { it.url == authorRelay }) {
|
||||
authorRelay
|
||||
} else {
|
||||
relays.firstOrNull()?.url
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,31 +437,36 @@ open class Note(
|
||||
}
|
||||
}
|
||||
|
||||
private fun recursiveIsPaidByCalculation(
|
||||
private suspend fun isPaidByCalculation(
|
||||
account: Account,
|
||||
remainingZapPayments: List<Pair<Note, Note?>>,
|
||||
zapEvents: List<Pair<Note, Note?>>,
|
||||
onWasZappedByAuthor: () -> Unit,
|
||||
) {
|
||||
if (remainingZapPayments.isEmpty()) {
|
||||
if (zapEvents.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val next = remainingZapPayments.first()
|
||||
var hasSentOne = false
|
||||
|
||||
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
|
||||
if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
|
||||
if (
|
||||
response is PayInvoiceSuccessResponse &&
|
||||
account.isNIP47Author(zapResponseEvent.requestAuthor())
|
||||
) {
|
||||
launchAndWaitAll(zapEvents) { next ->
|
||||
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
|
||||
|
||||
if (zapResponseEvent != null) {
|
||||
val result =
|
||||
tryAndWait { continuation ->
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
|
||||
if (
|
||||
response is PayInvoiceSuccessResponse &&
|
||||
account.isNIP47Author(zapResponseEvent.requestAuthor())
|
||||
) {
|
||||
continuation.resume(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSentOne && result == true) {
|
||||
hasSentOne = true
|
||||
onWasZappedByAuthor()
|
||||
} else {
|
||||
recursiveIsPaidByCalculation(
|
||||
account,
|
||||
remainingZapPayments.minus(next),
|
||||
onWasZappedByAuthor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,14 +476,14 @@ open class Note(
|
||||
option: Int?,
|
||||
user: User,
|
||||
account: Account,
|
||||
remainingZapEvents: Map<Note, Note?>,
|
||||
zapEvents: Map<Note, Note?>,
|
||||
onWasZappedByAuthor: () -> Unit,
|
||||
) {
|
||||
if (remainingZapEvents.isEmpty()) {
|
||||
if (zapEvents.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
remainingZapEvents.forEach { next ->
|
||||
zapEvents.forEach { next ->
|
||||
val zapRequest = next.key.event as LnZapRequestEvent
|
||||
val zapEvent = next.value?.event as? LnZapEvent
|
||||
|
||||
@@ -487,11 +506,9 @@ open class Note(
|
||||
} else {
|
||||
if (account.isWriteable()) {
|
||||
val result =
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
zapRequest.decryptPrivateZap(account.signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
tryAndWait { continuation ->
|
||||
zapRequest.decryptPrivateZap(account.signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +529,7 @@ open class Note(
|
||||
) {
|
||||
isZappedByCalculation(null, user, account, zaps, onWasZappedByAuthor)
|
||||
if (account.userProfile() == user) {
|
||||
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
|
||||
isPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,23 +581,79 @@ open class Note(
|
||||
zapsAmount = sumOfAmounts
|
||||
}
|
||||
|
||||
private fun recursiveZappedAmountCalculation(
|
||||
invoiceSet: LinkedHashSet<String>,
|
||||
remainingZapPayments: List<Pair<Note, Note?>>,
|
||||
private suspend fun zappedAmountCalculation(
|
||||
startAmount: BigDecimal,
|
||||
paidInvoiceSet: LinkedHashSet<String>,
|
||||
zapPayments: List<Pair<Note, Note?>>,
|
||||
signer: NostrSigner,
|
||||
output: BigDecimal,
|
||||
onReady: (BigDecimal) -> Unit,
|
||||
) {
|
||||
if (remainingZapPayments.isEmpty()) {
|
||||
onReady(output)
|
||||
if (zapPayments.isEmpty()) {
|
||||
onReady(startAmount)
|
||||
return
|
||||
}
|
||||
|
||||
val next = remainingZapPayments.first()
|
||||
var output: BigDecimal = startAmount
|
||||
|
||||
(next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent ->
|
||||
launchAndWaitAll(zapPayments) { next ->
|
||||
val result =
|
||||
tryAndWait { continuation ->
|
||||
processZapAmountFromResponse(
|
||||
next.first,
|
||||
next.second,
|
||||
continuation,
|
||||
signer,
|
||||
)
|
||||
}
|
||||
|
||||
if (result != null && !paidInvoiceSet.contains(result.invoice)) {
|
||||
paidInvoiceSet.add(result.invoice)
|
||||
output = output.add(result.amount)
|
||||
}
|
||||
}
|
||||
|
||||
onReady(output)
|
||||
}
|
||||
|
||||
private fun processZapAmountFromResponse(
|
||||
paymentRequest: Note,
|
||||
paymentResponse: Note?,
|
||||
continuation: CancellableContinuation<InvoiceAmount?>,
|
||||
signer: NostrSigner,
|
||||
) {
|
||||
val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent
|
||||
val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent
|
||||
|
||||
if (nwcRequest != null && nwcResponse != null) {
|
||||
processZapAmountFromResponse(
|
||||
nwcRequest,
|
||||
nwcResponse,
|
||||
continuation,
|
||||
signer,
|
||||
)
|
||||
} else {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
class InvoiceAmount(
|
||||
val invoice: String,
|
||||
val amount: BigDecimal,
|
||||
)
|
||||
|
||||
private fun processZapAmountFromResponse(
|
||||
nwcRequest: LnZapPaymentRequestEvent,
|
||||
nwcResponse: LnZapPaymentResponseEvent,
|
||||
continuation: CancellableContinuation<InvoiceAmount?>,
|
||||
signer: NostrSigner,
|
||||
) {
|
||||
// if we can decrypt the reply
|
||||
nwcResponse.response(signer) { noteEvent ->
|
||||
// if it is a sucess
|
||||
if (noteEvent is PayInvoiceSuccessResponse) {
|
||||
(next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice ->
|
||||
// if we can decrypt the invoice
|
||||
nwcRequest.lnInvoice(signer) { invoice ->
|
||||
// if we can parse the amount
|
||||
val amount =
|
||||
try {
|
||||
LnInvoiceUtil.getAmountInSats(invoice)
|
||||
@@ -589,26 +662,20 @@ open class Note(
|
||||
null
|
||||
}
|
||||
|
||||
var newAmount = output
|
||||
|
||||
if (amount != null && !invoiceSet.contains(invoice)) {
|
||||
invoiceSet.add(invoice)
|
||||
newAmount += amount
|
||||
// avoid double counting
|
||||
if (amount != null) {
|
||||
continuation.resume(InvoiceAmount(invoice, amount))
|
||||
} else {
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
recursiveZappedAmountCalculation(
|
||||
invoiceSet,
|
||||
remainingZapPayments.minus(next),
|
||||
signer,
|
||||
newAmount,
|
||||
onReady,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedAmountWithNWCPayments(
|
||||
suspend fun zappedAmountWithNWCPayments(
|
||||
signer: NostrSigner,
|
||||
onReady: (BigDecimal) -> Unit,
|
||||
) {
|
||||
@@ -619,11 +686,11 @@ open class Note(
|
||||
val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
|
||||
zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } }
|
||||
|
||||
recursiveZappedAmountCalculation(
|
||||
zappedAmountCalculation(
|
||||
zapsAmount,
|
||||
invoiceSet,
|
||||
zapPayments.toList(),
|
||||
signer,
|
||||
zapsAmount,
|
||||
onReady,
|
||||
)
|
||||
}
|
||||
@@ -771,29 +838,11 @@ open class Note(
|
||||
return true
|
||||
}
|
||||
|
||||
if (author?.toBestDisplayName()?.containsAny(accountChoices.hiddenWordsCase) == true) {
|
||||
if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) {
|
||||
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
|
||||
}
|
||||
if (author?.containsAny(accountChoices.hiddenWordsCase) == true) return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import kotlin.time.measureTimedValue
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
class ThreadAssembler {
|
||||
private fun searchRoot(
|
||||
@@ -71,32 +73,50 @@ class ThreadAssembler {
|
||||
return null
|
||||
}
|
||||
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
@Stable
|
||||
class ThreadInfo(
|
||||
val root: Note,
|
||||
val allNotes: ImmutableSet<Note>,
|
||||
)
|
||||
|
||||
fun findThreadFor(noteId: String): ThreadInfo? {
|
||||
checkNotInMainThread()
|
||||
|
||||
val (result, elapsed) =
|
||||
measureTimedValue {
|
||||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
|
||||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = OnlyLatestVersionSet()
|
||||
return if (note.event != null) {
|
||||
val thread = OnlyLatestVersionSet()
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
loadDown(threadRoot, thread)
|
||||
// adds the replies of the note in case the search for Root
|
||||
// did not added them.
|
||||
note.replies.forEach { loadDown(it, thread) }
|
||||
loadUp(note, thread)
|
||||
|
||||
thread
|
||||
} else {
|
||||
setOf(note)
|
||||
}
|
||||
}
|
||||
loadDown(threadRoot, thread)
|
||||
// adds the replies of the note in case the search for Root
|
||||
// did not added them.
|
||||
note.replies.forEach { loadDown(it, thread) }
|
||||
|
||||
println("Model Refresh: Thread loaded in $elapsed")
|
||||
ThreadInfo(
|
||||
root = note,
|
||||
allNotes = thread.toImmutableSet(),
|
||||
)
|
||||
} else {
|
||||
ThreadInfo(
|
||||
root = note,
|
||||
allNotes = setOf(note).toImmutableSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
fun loadUp(
|
||||
note: Note,
|
||||
thread: MutableSet<Note>,
|
||||
) {
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
note.replyTo?.forEach { loadUp(it, thread) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDown(
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import java.lang.Long.min
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -54,13 +55,23 @@ object ThreadLevelCalculator {
|
||||
now: Long,
|
||||
): LevelSignature {
|
||||
val replyTo = note.replyTo
|
||||
|
||||
// estimates the min date by replies if it doesn't exist.
|
||||
val createdAt =
|
||||
note.createdAt() ?: min(
|
||||
note.replies.minOfOrNull { it.createdAt() ?: now } ?: now,
|
||||
note.reactions.values.minOfOrNull { it.minOfOrNull { it.createdAt() ?: now } ?: now } ?: now,
|
||||
)
|
||||
|
||||
val noteAuthor = note.author
|
||||
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
return LevelSignature(
|
||||
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";",
|
||||
createdAt = note.createdAt(),
|
||||
author = note.author,
|
||||
signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";",
|
||||
createdAt = createdAt,
|
||||
author = noteAuthor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,23 +97,21 @@ object ThreadLevelCalculator {
|
||||
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
|
||||
|
||||
val threadOrder =
|
||||
if (parent?.author == note.author && note.createdAt() != null) {
|
||||
if (noteAuthor != null && parent?.author == noteAuthor) {
|
||||
// author of the thread first, in **ascending** order
|
||||
"9" +
|
||||
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) +
|
||||
note.idHex.substring(0, 8)
|
||||
} else if (note.author?.pubkeyHex == account.pubkeyHex) {
|
||||
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies
|
||||
} else if (note.author?.pubkeyHex in accountFollowingSet) {
|
||||
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
|
||||
"9" + formattedDateTime((parent.createdAt ?: 0) + (now - createdAt)) + note.idHex.substring(0, 8)
|
||||
} else if (noteAuthor != null && noteAuthor.pubkeyHex == account.pubkeyHex) {
|
||||
"8" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my replies
|
||||
} else if (noteAuthor != null && noteAuthor.pubkeyHex in accountFollowingSet) {
|
||||
"7" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my follows replies.
|
||||
} else {
|
||||
"0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else.
|
||||
"0" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // everyone else.
|
||||
}
|
||||
|
||||
val mySignature =
|
||||
LevelSignature(
|
||||
signature = parentSignature + "/" + threadOrder + ";",
|
||||
createdAt = note.createdAt(),
|
||||
signature = "$parentSignature/$threadOrder;",
|
||||
createdAt = createdAt,
|
||||
author = note.author,
|
||||
)
|
||||
|
||||
@@ -118,11 +127,17 @@ object ThreadLevelCalculator {
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
cachedLevels[note] = 0
|
||||
return 0
|
||||
}
|
||||
|
||||
return replyTo.maxOf {
|
||||
cachedLevels[it] ?: replyLevel(it, cachedLevels).apply { cachedLevels.put(it, this) }
|
||||
} + 1
|
||||
val thisLevel =
|
||||
replyTo.maxOf {
|
||||
cachedLevels[it] ?: replyLevel(it, cachedLevels)
|
||||
} + 1
|
||||
|
||||
cachedLevels[note] = thisLevel
|
||||
|
||||
return thisLevel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -337,19 +339,9 @@ class User(
|
||||
|
||||
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
|
||||
|
||||
fun isFollowingHashtag(tag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowTagSet()?.map { it.lowercase() }?.toSet()?.let {
|
||||
return tag.lowercase() in it
|
||||
}
|
||||
?: false
|
||||
}
|
||||
fun isFollowingHashtag(tag: String) = latestContactList?.isTaggedHash(tag) ?: false
|
||||
|
||||
fun isFollowingGeohash(geoTag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowAddressSet()?.toSet()?.let {
|
||||
return geoTag.lowercase() in it
|
||||
}
|
||||
?: false
|
||||
}
|
||||
fun isFollowingGeohash(geoTag: String) = latestContactList?.isTaggedGeoHash(geoTag) ?: false
|
||||
|
||||
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
|
||||
|
||||
@@ -370,6 +362,40 @@ class User(
|
||||
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
|
||||
} != null
|
||||
|
||||
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
|
||||
if (hiddenWordsCase.isEmpty()) return false
|
||||
|
||||
if (toBestDisplayName().containsAny(hiddenWordsCase)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (profilePicture()?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.banner?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.about?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.lud06?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.lud16?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.nip05?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
|
||||
|
||||
var liveSet: UserLiveSet? = null
|
||||
|
||||
@@ -22,17 +22,20 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import coil3.ImageLoader
|
||||
import coil3.Uri
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import java.util.Base64
|
||||
|
||||
@Stable
|
||||
@@ -51,8 +54,8 @@ class Base64Fetcher(
|
||||
val byteArray = Base64.getDecoder().decode(base64String)
|
||||
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
return ImageFetchResult(
|
||||
image = bitmap.asImage(true),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
@@ -66,13 +69,24 @@ class Base64Fetcher(
|
||||
data: Uri,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher? {
|
||||
return if (base64contentPattern.matcher(data.toString()).find()) {
|
||||
return Base64Fetcher(options, data)
|
||||
): Fetcher? =
|
||||
if (data.scheme == "data") {
|
||||
Base64Fetcher(options, data)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
object BKeyer : Keyer<Uri> {
|
||||
override fun key(
|
||||
data: Uri,
|
||||
options: Options,
|
||||
): String? =
|
||||
if (data.scheme == "data") {
|
||||
CryptoUtils.sha256(data.toString().toByteArray()).toHexKey()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.tryAndWait
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class BlossomUploader(
|
||||
val account: Account?,
|
||||
) {
|
||||
fun Context.getFileName(uri: Uri): String? =
|
||||
when (uri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
|
||||
else -> uri.path?.let(::File)?.name
|
||||
}
|
||||
|
||||
private fun Context.getContentFileName(uri: Uri): String? =
|
||||
runCatching {
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
forceProxy: (String) -> Boolean,
|
||||
context: Context,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val fileName = context.getFileName(uri)
|
||||
|
||||
val imageInputStreamForHash = contentResolver.openInputStream(uri)
|
||||
val payload =
|
||||
imageInputStreamForHash?.use {
|
||||
it.readBytes()
|
||||
}
|
||||
|
||||
checkNotNull(payload) { "Can't open the image input stream" }
|
||||
|
||||
val hash = CryptoUtils.sha256(payload).toHexKey()
|
||||
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
checkNotNull(imageInputStream) { "Can't open the image input stream" }
|
||||
|
||||
return uploadImage(
|
||||
imageInputStream,
|
||||
hash,
|
||||
payload.size,
|
||||
fileName,
|
||||
myContentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
server,
|
||||
forceProxy,
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
hash: HexKey,
|
||||
length: Int,
|
||||
fileName: String?,
|
||||
contentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: ServerName,
|
||||
forceProxy: (String) -> Boolean,
|
||||
context: Context,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val fileName = randomChars()
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val apiUrl = server.baseUrl.removeSuffix("/") + "/upload"
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
val requestBody: RequestBody =
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length.toLong()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
|
||||
authUploadHeader(
|
||||
hash,
|
||||
alt?.let { "Uploading $it" } ?: "Uploading $fileName",
|
||||
)?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
contentType?.let { requestBuilder.addHeader("Content-Type", it) }
|
||||
|
||||
requestBuilder
|
||||
.addHeader("Content-Length", length.toString())
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(apiUrl)
|
||||
.put(requestBody)
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body.use { body ->
|
||||
val str = body.string()
|
||||
val result = parseResults(str)
|
||||
return result
|
||||
}
|
||||
} else {
|
||||
val errorMessage = response.headers.get("X-Reason")
|
||||
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
server: ServerName,
|
||||
forceProxy: (String) -> Boolean,
|
||||
context: Context,
|
||||
): Boolean {
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val apiUrl = server.baseUrl
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
authDeleteHeader(
|
||||
hash,
|
||||
"Deleting $hash",
|
||||
)?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val request =
|
||||
requestBuilder
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(apiUrl.removeSuffix("/") + "/$hash.$extension")
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
return true
|
||||
} else {
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
if (explanation != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authUploadHeader(
|
||||
hash: String,
|
||||
alt: String,
|
||||
): String? {
|
||||
val myAccount = account ?: return null
|
||||
return tryAndWait { continuation ->
|
||||
myAccount.createBlossomUploadAuth(hash, alt) {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||
continuation.resume("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authDeleteHeader(
|
||||
hash: String,
|
||||
alt: String,
|
||||
): String? {
|
||||
val myAccount = account ?: return null
|
||||
return tryAndWait { continuation ->
|
||||
myAccount.createBlossomDeleteAuth(hash, alt) {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||
continuation.resume("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseResults(body: String): MediaUploadResult {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, MediaUploadResult::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaUploadResult(
|
||||
// A publicly accessible URL to the BUD-01 GET /<sha256> endpoint (optionally with a file extension)
|
||||
val url: String?,
|
||||
// The sha256 hash of the blob
|
||||
val sha256: HexKey? = null,
|
||||
// The size of the blob in bytes
|
||||
val size: Long? = null,
|
||||
// (optional) The MIME type of the blob
|
||||
val type: String? = null,
|
||||
// upload time
|
||||
val uploaded: Long? = null,
|
||||
// dimensions
|
||||
val dimension: Dimension? = null,
|
||||
// magnet link
|
||||
val magnet: String? = null,
|
||||
val infohash: String? = null,
|
||||
// ipfs link
|
||||
val ipfs: String? = null,
|
||||
)
|
||||
@@ -20,60 +20,52 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
|
||||
class Blurhash(
|
||||
val blurhash: String,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri,
|
||||
private val data: Blurhash,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8")
|
||||
val hash = data.blurhash
|
||||
|
||||
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
|
||||
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data")
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
return ImageFetchResult(
|
||||
image = bitmap.asImage(true),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
object Factory : Fetcher.Factory<Blurhash> {
|
||||
override fun create(
|
||||
data: Uri,
|
||||
data: Blurhash,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher = BlurHashFetcher(options, data)
|
||||
}
|
||||
}
|
||||
|
||||
object BlurHashRequester {
|
||||
fun imageRequest(
|
||||
context: Context,
|
||||
message: String,
|
||||
): ImageRequest {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("bluehash:$encodedMessage")
|
||||
.fetcherFactory(BlurHashFetcher.Factory)
|
||||
.build()
|
||||
object BKeyer : Keyer<Blurhash> {
|
||||
override fun key(
|
||||
data: Blurhash,
|
||||
options: Options,
|
||||
): String = data.blurhash
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import android.util.Log
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.IOException
|
||||
@@ -40,14 +41,14 @@ class FileHeader(
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val dim: Dimension?,
|
||||
val blurHash: String?,
|
||||
) {
|
||||
companion object {
|
||||
suspend fun prepare(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
dimPrecomputed: Dimension?,
|
||||
forceProxy: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -70,7 +71,7 @@ class FileHeader(
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
dimPrecomputed: Dimension?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
@@ -95,7 +96,7 @@ class FileHeader(
|
||||
mBitmap.height,
|
||||
)
|
||||
|
||||
val dim = "${mBitmap.width}x${mBitmap.height}"
|
||||
val dim = Dimension(mBitmap.width, mBitmap.height)
|
||||
|
||||
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||
|
||||
@@ -166,7 +167,7 @@ class FileHeader(
|
||||
}
|
||||
}
|
||||
|
||||
if (newDim != "0x0") {
|
||||
if (newDim?.hasSize() == true) {
|
||||
Pair(blurhash, newDim)
|
||||
} else {
|
||||
Pair(blurhash, null)
|
||||
@@ -212,11 +213,15 @@ fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
|
||||
fun MediaMetadataRetriever.prepareDimFromVideo(): Dimension? {
|
||||
val width = prepareVideoWidth() ?: return null
|
||||
val height = prepareVideoHeight() ?: return null
|
||||
|
||||
return "${width}x$height"
|
||||
return if (width > 0 && height > 0) {
|
||||
Dimension(width, height)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.fonfon.kgeohash.GeoHash
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_DISTANCE
|
||||
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_TIME
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LocationFlow(
|
||||
private val context: Context,
|
||||
) {
|
||||
@SuppressLint("MissingPermission")
|
||||
fun get(
|
||||
minTimeMs: Long = MIN_TIME,
|
||||
minDistanceM: Float = MIN_DISTANCE,
|
||||
): Flow<Location> =
|
||||
callbackFlow {
|
||||
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
val locationCallback =
|
||||
object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
launch { send(location) }
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {}
|
||||
}
|
||||
|
||||
Log.d("Location Service", "LocationState Start")
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
locationCallback,
|
||||
Looper.getMainLooper(),
|
||||
)
|
||||
|
||||
awaitClose {
|
||||
locationManager.removeUpdates(locationCallback)
|
||||
Log.d("Location Service", "LocationState Stop")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LocationState(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 10000L
|
||||
const val MIN_DISTANCE: Float = 100.0f
|
||||
}
|
||||
|
||||
sealed class LocationResult {
|
||||
data class Success(
|
||||
val geoHash: GeoHash,
|
||||
) : LocationResult()
|
||||
|
||||
object LackPermission : LocationResult()
|
||||
|
||||
object Loading : LocationResult()
|
||||
}
|
||||
|
||||
private var hasLocationPermission = MutableStateFlow<Boolean>(false)
|
||||
private var latestLocation: LocationResult = LocationResult.Loading
|
||||
|
||||
fun setLocationPermission(newValue: Boolean) {
|
||||
if (newValue != hasLocationPermission.value) {
|
||||
hasLocationPermission.tryEmit(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val geohashStateFlow =
|
||||
hasLocationPermission
|
||||
.transformLatest {
|
||||
emitAll(
|
||||
LocationFlow
|
||||
(context)
|
||||
.get(MIN_TIME, MIN_DISTANCE)
|
||||
.map {
|
||||
LocationResult.Success(it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)) as LocationResult
|
||||
}.onEach {
|
||||
latestLocation = it
|
||||
}.catch { e ->
|
||||
e.printStackTrace()
|
||||
latestLocation = LocationResult.LackPermission
|
||||
emit(LocationResult.LackPermission)
|
||||
},
|
||||
)
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
latestLocation,
|
||||
)
|
||||
}
|
||||
|
||||
object CachedGeoLocations {
|
||||
val locationNames = LruCache<String, String>(20)
|
||||
|
||||
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
|
||||
|
||||
suspend fun geoLocate(
|
||||
geoHashStr: String,
|
||||
location: Location,
|
||||
context: Context,
|
||||
): String? {
|
||||
locationNames[geoHashStr]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
|
||||
|
||||
if (name != null) {
|
||||
locationNames.put(geoHashStr, name)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
private class ReverseGeoLocationUtil {
|
||||
suspend fun execute(
|
||||
location: Location,
|
||||
context: Context,
|
||||
): String? {
|
||||
return try {
|
||||
Geocoder(context)
|
||||
.getFromLocation(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
1,
|
||||
)?.firstOrNull()
|
||||
?.let { address ->
|
||||
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
|
||||
.joinToString(", ")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReverseGeoLocationFlow(
|
||||
private val context: Context,
|
||||
) {
|
||||
@SuppressLint("MissingPermission")
|
||||
fun get(location: Location): Flow<String?> =
|
||||
callbackFlow {
|
||||
val locationManager = Geocoder(context)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val locationCallback =
|
||||
(
|
||||
Geocoder.GeocodeListener { addresses ->
|
||||
launch {
|
||||
send(
|
||||
addresses.firstOrNull()?.let {
|
||||
listOfNotNull(it.locality ?: it.subAdminArea, it.countryCode).joinToString(", ")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
Log.d("GeoLocation Service", "LocationState Start")
|
||||
|
||||
locationManager
|
||||
.getFromLocation(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
1,
|
||||
locationCallback,
|
||||
)
|
||||
} else {
|
||||
launch {
|
||||
send(
|
||||
Geocoder(context)
|
||||
.getFromLocation(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
1,
|
||||
)?.firstOrNull()
|
||||
?.let { address ->
|
||||
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
|
||||
.joinToString(", ")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.HandlerThread
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class LocationUtil(
|
||||
context: Context,
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 1000L
|
||||
const val MIN_DISTANCE: Float = 0.0f
|
||||
}
|
||||
|
||||
private val locationManager =
|
||||
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private var locationListener: LocationListener? = null
|
||||
|
||||
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
|
||||
val providerState = mutableStateOf(false)
|
||||
val isStart: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
private val locHandlerThread = HandlerThread("LocationUtil Thread")
|
||||
|
||||
init {
|
||||
locHandlerThread.start()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(
|
||||
minTimeMs: Long = MIN_TIME,
|
||||
minDistanceM: Float = MIN_DISTANCE,
|
||||
) {
|
||||
locationListener().let {
|
||||
locationListener = it
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
it,
|
||||
locHandlerThread.looper,
|
||||
)
|
||||
}
|
||||
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
isStart.value = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
locationListener?.let { locationManager.removeUpdates(it) }
|
||||
isStart.value = false
|
||||
}
|
||||
|
||||
private fun locationListener() =
|
||||
object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
locationStateFlow.value = location
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
providerState.value = true
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
providerState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object CachedGeoLocations {
|
||||
val locationNames = LruCache<String, String>(20)
|
||||
|
||||
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
|
||||
|
||||
suspend fun geoLocate(
|
||||
geoHashStr: String,
|
||||
location: Location,
|
||||
context: Context,
|
||||
): String? {
|
||||
locationNames[geoHashStr]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
|
||||
|
||||
if (name != null) {
|
||||
locationNames.put(geoHashStr, name)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
private class ReverseGeoLocationUtil {
|
||||
suspend fun execute(
|
||||
location: Location,
|
||||
context: Context,
|
||||
): String? {
|
||||
return try {
|
||||
Geocoder(context)
|
||||
.getFromLocation(location.latitude, location.longitude, 1)
|
||||
?.firstOrNull()
|
||||
?.let { address ->
|
||||
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
|
||||
.joinToString(", ")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,20 +31,6 @@ import java.net.URI
|
||||
import java.net.URL
|
||||
|
||||
object Nip96MediaServers {
|
||||
val DEFAULT =
|
||||
listOf(
|
||||
ServerName("Nostr.Build", "https://nostr.build"),
|
||||
ServerName("NostrCheck.me", "https://nostrcheck.me"),
|
||||
ServerName("NostPic", "https://nostpic.com"),
|
||||
ServerName("Sovbit", "https://files.sovbit.host"),
|
||||
ServerName("Void.cat", "https://void.cat"),
|
||||
)
|
||||
|
||||
data class ServerName(
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
)
|
||||
|
||||
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
||||
|
||||
suspend fun load(
|
||||
@@ -136,23 +122,23 @@ class Nip96Retriever {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -32,11 +32,12 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.tryAndWait
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
@@ -60,12 +61,12 @@ class Nip96Uploader(
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96MediaServers.ServerName,
|
||||
server: ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
): MediaUploadResult {
|
||||
val serverInfo =
|
||||
Nip96Retriever()
|
||||
.loadInfo(
|
||||
@@ -98,7 +99,7 @@ class Nip96Uploader(
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
@@ -138,7 +139,7 @@ class Nip96Uploader(
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val fileName = randomChars()
|
||||
@@ -190,7 +191,7 @@ class Nip96Uploader(
|
||||
if (!result.processingUrl.isNullOrBlank()) {
|
||||
return waitProcessing(result, server, forceProxy, onProgress)
|
||||
} else if (result.status == "success" && result.nip94Event != null) {
|
||||
return result.nip94Event
|
||||
return convertToMediaResult(result.nip94Event)
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
|
||||
}
|
||||
@@ -224,6 +225,40 @@ class Nip96Uploader(
|
||||
}
|
||||
}
|
||||
|
||||
fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType =
|
||||
nip96.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val originalHash =
|
||||
nip96.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val dim =
|
||||
nip96.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "dim" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
?.let { Dimension.parse(it) }
|
||||
val magnet =
|
||||
nip96.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
|
||||
return MediaUploadResult(
|
||||
url = imageUrl,
|
||||
type = remoteMimeType,
|
||||
sha256 = originalHash,
|
||||
dimension = dim,
|
||||
magnet = magnet,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun delete(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
@@ -270,7 +305,7 @@ class Nip96Uploader(
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
): PartialEvent {
|
||||
): MediaUploadResult {
|
||||
var currentResult = result
|
||||
|
||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||
@@ -297,17 +332,15 @@ class Nip96Uploader(
|
||||
val nip94 = currentResult.nip94Event
|
||||
|
||||
if (nip94 != null) {
|
||||
return nip94
|
||||
return convertToMediaResult(nip94)
|
||||
} else {
|
||||
throw RuntimeException("Error waiting for processing. Final result is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nip98Header(url: String): String? =
|
||||
withTimeoutOrNull(5000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
|
||||
}
|
||||
tryAndWait { continuation ->
|
||||
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
|
||||
}
|
||||
|
||||
fun nip98Header(
|
||||
|
||||
+28
-28
@@ -33,14 +33,17 @@ import com.vitorpamplona.ammolite.relays.filters.EOSETime
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
@@ -53,6 +56,8 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
@@ -78,44 +83,25 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
|
||||
fun createAccountRelayListFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
MetadataEvent.KIND,
|
||||
ContactListEvent.KIND,
|
||||
StatusEvent.KIND,
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
ChatMessageRelayListEvent.KIND,
|
||||
SearchRelayListEvent.KIND,
|
||||
FileServersEvent.KIND,
|
||||
BlossomServersEvent.KIND,
|
||||
PrivateOutboxRelayListEvent.KIND,
|
||||
),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 10,
|
||||
limit = 20,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -134,11 +120,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
ChatMessageRelayListEvent.KIND,
|
||||
SearchRelayListEvent.KIND,
|
||||
FileServersEvent.KIND,
|
||||
BlossomServersEvent.KIND,
|
||||
MuteListEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
),
|
||||
authors = otherAuthors,
|
||||
limit = 100,
|
||||
limit = otherAuthors.size * 20,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -154,6 +141,18 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
),
|
||||
)
|
||||
|
||||
fun createAccountSettings2Filter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(AppSpecificDataEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
@@ -244,9 +243,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
CalendarTimeSlotEvent.KIND,
|
||||
CalendarRSVPEvent.KIND,
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
InteractiveStorySceneEvent.KIND,
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 400,
|
||||
@@ -465,8 +467,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
accountChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
createAccountSettings2Filter(),
|
||||
createNotificationFilter(),
|
||||
createNotificationFilter2(),
|
||||
createGiftWrapsToMeFilter(),
|
||||
@@ -480,9 +481,8 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
accountChannel.typedFilters =
|
||||
listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
createAccountSettingsFilter(),
|
||||
createAccountSettings2Filter(),
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
fun createLiveStreamFilter(): List<TypedFilter> {
|
||||
val follows =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.users
|
||||
?.authors
|
||||
?.toList()
|
||||
?.ifEmpty { null }
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
@@ -57,12 +57,12 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
|
||||
ChannelMessageEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
),
|
||||
|
||||
+48
-32
@@ -27,7 +27,9 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
@@ -37,45 +39,59 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") {
|
||||
private var hashtagToWatch: String? = null
|
||||
|
||||
fun createLoadHashtagFilter(): TypedFilter? {
|
||||
val hashToLoad = hashtagToWatch ?: return null
|
||||
fun createLoadHashtagFilter(): List<TypedFilter> {
|
||||
val hashToLoad = hashtagToWatch ?: return emptyList()
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
listOf(
|
||||
hashToLoad,
|
||||
hashToLoad.lowercase(),
|
||||
hashToLoad.uppercase(),
|
||||
hashToLoad.capitalize(),
|
||||
),
|
||||
),
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
),
|
||||
val hashtagsToFollow =
|
||||
listOf(
|
||||
hashToLoad,
|
||||
hashToLoad.lowercase(),
|
||||
hashToLoad.uppercase(),
|
||||
hashToLoad.capitalize(),
|
||||
)
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags = mapOf("t" to hashtagsToFollow),
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags = mapOf("t" to hashtagsToFollow),
|
||||
kinds =
|
||||
listOf(
|
||||
InteractiveStorySceneEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val loadHashtagChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
|
||||
loadHashtagChannel.typedFilters = createLoadHashtagFilter().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadHashtag(tag: String?) {
|
||||
|
||||
@@ -31,9 +31,11 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
@@ -85,38 +87,57 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
job2?.cancel()
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
fun createFollowAccountsFilter(): List<TypedFilter> {
|
||||
val follows =
|
||||
account.liveHomeListAuthorsPerRelay.value
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
authors = follows,
|
||||
limit = 400,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
),
|
||||
authors = follows,
|
||||
limit = 400,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
authors = follows,
|
||||
limit = 400,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,8 +185,8 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
@@ -202,16 +223,15 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
LongTextNoteEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.map { listOf(it.lowercase()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 100,
|
||||
@@ -225,7 +245,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowCommunitiesFilter(): TypedFilter? {
|
||||
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
|
||||
val communitiesToLoad = account.liveHomeFollowLists.value?.addresses ?: return null
|
||||
|
||||
if (communitiesToLoad.isEmpty()) return null
|
||||
|
||||
@@ -239,11 +259,10 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
LongTextNoteEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
CommunityPostApprovalEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
mapOf(
|
||||
@@ -271,12 +290,14 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createFollowAccountsFilter(),
|
||||
createFollowMetadataAndReleaseFilter(),
|
||||
createFollowCommunitiesFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter(),
|
||||
(
|
||||
createFollowAccountsFilter() +
|
||||
listOfNotNull(
|
||||
createFollowMetadataAndReleaseFilter(),
|
||||
createFollowCommunitiesFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter(),
|
||||
)
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -38,9 +38,12 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStorySceneEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
@@ -177,6 +180,20 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
|
||||
PollNoteEvent.KIND,
|
||||
NNSEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
InteractiveStorySceneEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
|
||||
+43
-14
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
@@ -28,26 +29,55 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
|
||||
private var eventToWatch: String? = null
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val threadToLoad = eventToWatch ?: return null
|
||||
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter> {
|
||||
val threadToLoad = eventToWatch ?: return emptyList()
|
||||
|
||||
val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList()
|
||||
|
||||
val eventsToLoad =
|
||||
ThreadAssembler()
|
||||
.findThreadFor(threadToLoad)
|
||||
branch.allNotes
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex }
|
||||
.toSet()
|
||||
.ifEmpty { null }
|
||||
?: return null
|
||||
|
||||
if (eventsToLoad.isEmpty()) return null
|
||||
val address = if (branch.root is AddressableNote) branch.root.idHex else null
|
||||
val event = if (branch.root !is AddressableNote) branch.root.idHex else branch.root.event?.id()
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
ids = eventsToLoad.toList(),
|
||||
),
|
||||
return listOfNotNull(
|
||||
eventsToLoad?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
ids = it.toList(),
|
||||
),
|
||||
)
|
||||
},
|
||||
event?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags =
|
||||
mapOf(
|
||||
"e" to listOf(event),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
address?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags =
|
||||
mapOf(
|
||||
"a" to listOf(address),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,8 +89,7 @@ object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.typedFilters =
|
||||
listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter()
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String?) {
|
||||
|
||||
+2
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
@@ -101,6 +102,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
|
||||
listOf(
|
||||
TorrentEvent.KIND,
|
||||
TorrentCommentEvent.KIND,
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 20,
|
||||
|
||||
+117
-62
@@ -29,6 +29,7 @@ import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.PictureEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -64,85 +65,139 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun createContextualFilter(): TypedFilter {
|
||||
fun createContextualFilter(): List<TypedFilter> {
|
||||
val follows = account.liveStoriesListAuthorsPerRelay.value
|
||||
|
||||
return TypedFilter(
|
||||
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
limit = 200,
|
||||
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
val types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS)
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = types,
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
limit = 200,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = types,
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
|
||||
limit = 200,
|
||||
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
fun createFollowTagsFilter(): List<TypedFilter> {
|
||||
val hashToLoad =
|
||||
account.liveStoriesFollowLists.value
|
||||
?.hashtags
|
||||
?.toList() ?: return null
|
||||
?.toList() ?: return emptyList()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
if (hashToLoad.isEmpty()) return emptyList()
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
val hashtags =
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten()
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
tags = mapOf("t" to hashtags),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to hashtags,
|
||||
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
fun createFollowGeohashesFilter(): List<TypedFilter> {
|
||||
val hashToLoad =
|
||||
account.liveStoriesFollowLists.value
|
||||
?.geotags
|
||||
?.toList() ?: return null
|
||||
?.toList() ?: return emptyList()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
if (hashToLoad.isEmpty()) return emptyList()
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
val geoHashes =
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten()
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
tags = mapOf("g" to geoHashes),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to geoHashes,
|
||||
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,6 +217,6 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
createContextualFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter(),
|
||||
).ifEmpty { null }
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,12 @@ package com.vitorpamplona.amethyst.service
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.collectSuccessfulOperations
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
@@ -195,8 +194,8 @@ class ZapPaymentHandler(
|
||||
)?.readRelays()
|
||||
}?.toSet()
|
||||
|
||||
collectSuccessfulSigningOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
|
||||
operationsInput = zapsToSend,
|
||||
collectSuccessfulOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
|
||||
items = zapsToSend,
|
||||
runRequestFor = { next: ZapSplitSetup, onReady ->
|
||||
if (next.isLnAddress) {
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
|
||||
@@ -239,8 +238,8 @@ class ZapPaymentHandler(
|
||||
var progressAllPayments = 0.00f
|
||||
val totalWeight = invoices.sumOf { it.first.weight }
|
||||
|
||||
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
|
||||
operationsInput = invoices,
|
||||
collectSuccessfulOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
|
||||
items = invoices,
|
||||
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, SignAllZapRequestsReturn>, onReady ->
|
||||
assembleInvoice(
|
||||
splitSetup = splitZapRequestPair.first,
|
||||
@@ -272,8 +271,8 @@ class ZapPaymentHandler(
|
||||
) {
|
||||
var progressAllPayments = 0.00f
|
||||
|
||||
collectSuccessfulSigningOperations<String, Boolean>(
|
||||
operationsInput = invoices,
|
||||
collectSuccessfulOperations<String, Boolean>(
|
||||
items = invoices,
|
||||
runRequestFor = { invoice: String, onReady ->
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = invoice,
|
||||
|
||||
+2
-1
@@ -154,8 +154,9 @@ class EventNotificationConsumer(
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
if (
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
// old event being re-broadcasted
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
// don't display if it comes from me.
|
||||
event.pubKey != signer.pubKey
|
||||
) { // from the user
|
||||
Log.d(TAG, "Notifying")
|
||||
|
||||
+5
-4
@@ -29,9 +29,10 @@ import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import coil.ImageLoader
|
||||
import coil.executeBlocking
|
||||
import coil.request.ImageRequest
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.executeBlocking
|
||||
import coil3.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -157,7 +158,7 @@ object NotificationUtils {
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
time = time,
|
||||
picture = imageResult.drawable as? BitmapDrawable,
|
||||
picture = imageResult.image?.asDrawable(applicationContext.resources) as? BitmapDrawable,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
|
||||
+18
-33
@@ -25,18 +25,15 @@ import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.launchAndWaitAll
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.tryAndWait
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -63,38 +60,26 @@ class RegisterAccounts(
|
||||
return
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
remainingTos.map { accountRelayPair ->
|
||||
async {
|
||||
val result =
|
||||
withTimeoutOrNull(10000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
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,
|
||||
)
|
||||
}
|
||||
launchAndWaitAll(remainingTos) { accountRelayPair ->
|
||||
val result =
|
||||
tryAndWait { continuation ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
output.add(result)
|
||||
}
|
||||
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
// runs in parallel to avoid overcrowding Amber.
|
||||
withTimeoutOrNull(15000) {
|
||||
jobs.joinAll()
|
||||
if (result != null) {
|
||||
output.add(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,11 +85,12 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
@@ -97,6 +98,9 @@ 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.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ImageVideoDescription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
@@ -333,8 +337,8 @@ fun EditPostView(
|
||||
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.settings.changeDefaultFileServer(server.server)
|
||||
if (server.type != ServerType.NIP95) {
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||
}
|
||||
},
|
||||
onCancel = { postViewModel.contentToAddUrl = null },
|
||||
|
||||
@@ -38,9 +38,13 @@ 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.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -150,7 +154,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: Int,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
server: ServerName,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
@@ -167,7 +171,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.isNip95) {
|
||||
if (server.type == ServerType.NIP95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
@@ -180,7 +184,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
context,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (server.type == ServerType.NIP96) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
@@ -191,13 +195,52 @@ open class EditPostViewModel : ViewModel() {
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
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
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
@@ -274,7 +317,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions =
|
||||
LocalCache
|
||||
.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.findUsersStartingWith(lastWord.removePrefix("@"), account)
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
|
||||
.reversed()
|
||||
}
|
||||
@@ -316,7 +359,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
contentToAddUrl == null
|
||||
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
uploadingResult: MediaUploadResult,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
@@ -324,30 +367,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
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)
|
||||
val remoteMimeType =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val originalHash =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val dim =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "dim" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val magnet =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
if (uploadingResult.url.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
@@ -356,16 +376,16 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
forceProxy = forceProxy(imageUrl),
|
||||
fileUrl = uploadingResult.url,
|
||||
mimeType = uploadingResult.type ?: localContentType,
|
||||
dimPrecomputed = uploadingResult.dimension,
|
||||
forceProxy = forceProxy(uploadingResult.url),
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
|
||||
isUploadingImage = false
|
||||
nip94attachments = nip94attachments + event
|
||||
|
||||
message = message.insertUrlAtCursor(imageUrl)
|
||||
message = message.insertUrlAtCursor(uploadingResult.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -80,6 +80,7 @@ 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.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@@ -47,6 +47,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
|
||||
@@ -31,9 +31,13 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
@@ -41,11 +45,6 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ServerOption(
|
||||
val server: Nip96MediaServers.ServerName,
|
||||
val isNip95: Boolean,
|
||||
)
|
||||
|
||||
@Stable
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
@@ -53,7 +52,7 @@ open class NewMediaModel : ViewModel() {
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
var mediaType by mutableStateOf<String?>(null)
|
||||
|
||||
var selectedServer by mutableStateOf<ServerOption?>(null)
|
||||
var selectedServer by mutableStateOf<ServerName?>(null)
|
||||
var alt by mutableStateOf("")
|
||||
var sensitiveContent by mutableStateOf(false)
|
||||
|
||||
@@ -73,7 +72,7 @@ open class NewMediaModel : ViewModel() {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = ServerOption(defaultServer(), false)
|
||||
this.selectedServer = defaultServer()
|
||||
}
|
||||
|
||||
fun upload(
|
||||
@@ -99,7 +98,7 @@ open class NewMediaModel : ViewModel() {
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (serverToUse.isNip95) {
|
||||
if (serverToUse.type == ServerType.NIP95) {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Loading"
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
@@ -121,7 +120,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingDescription.value = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (serverToUse.type == ServerType.NIP96) {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Uploading"
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -134,7 +133,7 @@ open class NewMediaModel : ViewModel() {
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = serverToUse.server,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = { percent: Float ->
|
||||
@@ -143,6 +142,43 @@ open class NewMediaModel : ViewModel() {
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
relayList = relayList,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
|
||||
}
|
||||
}
|
||||
} else if (serverToUse.type == ServerType.Blossom) {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Uploading"
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
@@ -182,13 +218,13 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
alt = ""
|
||||
selectedServer = ServerOption(defaultServer(), false)
|
||||
selectedServer = defaultServer()
|
||||
}
|
||||
|
||||
fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
uploadingResult: MediaUploadResult,
|
||||
localContentType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
@@ -201,29 +237,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val originalHash =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val dim =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "dim" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val magnet =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
if (uploadingResult.url.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
@@ -236,7 +250,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl, forceProxy(imageUrl))
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadingResult.url, forceProxy(uploadingResult.url))
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
@@ -244,18 +258,18 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
FileHeader.prepare(
|
||||
data = imageData,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
mimeType = uploadingResult.type ?: localContentType,
|
||||
dimPrecomputed = uploadingResult.dimension,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(
|
||||
imageUrl,
|
||||
magnet,
|
||||
uploadingResult.url,
|
||||
uploadingResult.magnet,
|
||||
it,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
originalHash,
|
||||
uploadingResult.sha256,
|
||||
relayList,
|
||||
) {
|
||||
uploadingPercentage.value = 1.00f
|
||||
@@ -338,7 +352,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
|
||||
@@ -30,8 +30,10 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -40,17 +42,21 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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
|
||||
@@ -67,23 +73,28 @@ 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
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
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.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingSwitchItem
|
||||
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.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewMediaView(
|
||||
uri: Uri,
|
||||
@@ -118,9 +129,66 @@ fun NewMediaView(
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
SetDialogToEdgeToEdge()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = { showRelaysDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context, relayList, mediaQualitySlider) {
|
||||
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
}
|
||||
postViewModel.selectedServer?.let {
|
||||
if (it.type != ServerType.NIP95) {
|
||||
account.settings.changeDefaultFileServer(it)
|
||||
}
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost(),
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Row {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
preSelectedList = relayList,
|
||||
@@ -131,61 +199,21 @@ fun NewMediaView(
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CloseButton(
|
||||
onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = { showRelaysDialog = true },
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
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.settings.changeDefaultFileServer(it.server)
|
||||
}
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost(),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).verticalScroll(scrollState),
|
||||
) {
|
||||
ImageVideoPost(postViewModel, accountViewModel)
|
||||
|
||||
@@ -194,7 +222,6 @@ fun NewMediaView(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
@@ -254,39 +281,21 @@ fun ImageVideoPost(
|
||||
postViewModel: NewMediaModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val listOfNip96ServersNote =
|
||||
accountViewModel.account
|
||||
.getFileServersNote()
|
||||
.live()
|
||||
.metadata
|
||||
.observeAsState()
|
||||
|
||||
val fileServers =
|
||||
(
|
||||
(listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map {
|
||||
ServerOption(
|
||||
Nip96MediaServers.ServerName(
|
||||
it,
|
||||
it,
|
||||
),
|
||||
false,
|
||||
)
|
||||
} ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) }
|
||||
) +
|
||||
listOf(
|
||||
ServerOption(
|
||||
Nip96MediaServers.ServerName(
|
||||
"NIP95",
|
||||
stringRes(id = R.string.upload_server_relays_nip95),
|
||||
),
|
||||
true,
|
||||
),
|
||||
)
|
||||
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
|
||||
val fileServers by accountViewModel.account.liveServerList.collectAsState()
|
||||
|
||||
val fileServerOptions =
|
||||
remember {
|
||||
fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList()
|
||||
remember(fileServers) {
|
||||
fileServers
|
||||
.map {
|
||||
if (it.type == ServerType.NIP95) {
|
||||
TitleExplainer(it.name, nip95description)
|
||||
} else {
|
||||
TitleExplainer(it.name, it.baseUrl)
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
Row(
|
||||
@@ -353,10 +362,9 @@ fun ImageVideoPost(
|
||||
label = stringRes(id = R.string.file_server),
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
|
||||
?.server
|
||||
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
|
||||
?.name
|
||||
?: fileServers[0].server.name,
|
||||
?: fileServers[0].name,
|
||||
options = fileServerOptions,
|
||||
onSelect = { postViewModel.selectedServer = fileServers[it] },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
|
||||
|
||||
@@ -39,29 +39,23 @@ class NewMessageTagger(
|
||||
var dao: Dao,
|
||||
) {
|
||||
val directMentions = mutableSetOf<HexKey>()
|
||||
val directMentionsNotes = mutableSetOf<Note>()
|
||||
val directMentionsUsers = mutableSetOf<User>()
|
||||
|
||||
fun addUserToMentions(user: User) {
|
||||
directMentionsUsers.add(user)
|
||||
directMentions.add(user.pubkeyHex)
|
||||
pTags = if (pTags?.contains(user) == true) pTags else pTags?.plus(user) ?: listOf(user)
|
||||
}
|
||||
|
||||
fun addNoteToReplyTos(note: Note) {
|
||||
directMentionsNotes.add(note)
|
||||
directMentions.add(note.idHex)
|
||||
|
||||
note.author?.let { addUserToMentions(it) }
|
||||
eTags = if (eTags?.contains(note) == true) eTags else eTags?.plus(note) ?: listOf(note)
|
||||
}
|
||||
|
||||
fun tagIndex(user: User): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channelHex != null) 1 else 0) + (eTags?.size ?: 0) + (pTags?.indexOf(user) ?: 0)
|
||||
}
|
||||
|
||||
fun tagIndex(note: Note): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channelHex != null) 1 else 0) + (eTags?.indexOf(note) ?: 0)
|
||||
}
|
||||
|
||||
suspend fun run() {
|
||||
// adds all references to mentions and reply tos
|
||||
message.split('\n').forEach { paragraph: String ->
|
||||
|
||||
@@ -34,7 +34,7 @@ import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -42,10 +42,14 @@ 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.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.LocationUtil
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -59,6 +63,7 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
@@ -68,6 +73,7 @@ import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.RootScope
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
@@ -75,10 +81,8 @@ 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.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
@@ -163,8 +167,8 @@ open class NewPostViewModel : ViewModel() {
|
||||
|
||||
// GeoHash
|
||||
var wantsToAddGeoHash by mutableStateOf(false)
|
||||
var locUtil: LocationUtil? = null
|
||||
var location: Flow<String>? = null
|
||||
var location: StateFlow<LocationState.LocationResult>? = null
|
||||
var wantsExclusiveGeoPost by mutableStateOf(false)
|
||||
|
||||
// ZapRaiser
|
||||
var canAddZapRaiser by mutableStateOf(false)
|
||||
@@ -220,7 +224,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
if (replyNote.event !is CommunityDefinitionEvent) {
|
||||
replyNote.author?.let { replyUser ->
|
||||
val currentMentions =
|
||||
(replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) }
|
||||
(replyNote.event as? TextNoteEvent)
|
||||
?.mentions()
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.map { LocalCache.getOrCreateUser(it) }
|
||||
?: emptyList()
|
||||
|
||||
if (currentMentions.contains(replyUser)) {
|
||||
@@ -336,7 +343,13 @@ open class NewPostViewModel : ViewModel() {
|
||||
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
|
||||
|
||||
wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" }
|
||||
wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" }
|
||||
|
||||
val geohash = draftEvent.getGeoHash()
|
||||
wantsToAddGeoHash = geohash != null
|
||||
if (geohash != null) {
|
||||
wantsExclusiveGeoPost = draftEvent.kind() == CommentEvent.KIND
|
||||
}
|
||||
|
||||
val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" }
|
||||
wantsZapraiser = zapraiser.isNotEmpty()
|
||||
zapRaiserAmount = null
|
||||
@@ -530,14 +543,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
null
|
||||
}
|
||||
|
||||
val geoLocation = locUtil?.locationStateFlow?.value
|
||||
val geoHash =
|
||||
if (wantsToAddGeoHash && geoLocation != null) {
|
||||
geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
|
||||
|
||||
nip95attachments.forEach {
|
||||
@@ -551,7 +557,37 @@ open class NewPostViewModel : ViewModel() {
|
||||
// Doesn't send as nip94 yet because we don't know if it makes sense.
|
||||
// usedAttachments.forEach { account?.sendHeader(it, relayList, {}) }
|
||||
|
||||
if (originalNote?.channelHex() != null) {
|
||||
val replyingTo = originalNote
|
||||
|
||||
if (replyingTo?.event is CommentEvent || (replyingTo?.event is Event && replyingTo.event is RootScope)) {
|
||||
account?.sendReplyComment(
|
||||
message = tagger.message,
|
||||
replyingTo = replyingTo,
|
||||
directMentionsUsers = tagger.directMentionsUsers,
|
||||
directMentionsNotes = tagger.directMentionsNotes,
|
||||
nip94attachments = usedAttachments,
|
||||
geohash = geoHash,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
relayList = relayList,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (wantsExclusiveGeoPost && geoHash != null && (originalNote == null || originalNote?.event is CommentEvent)) {
|
||||
account?.sendGeoComment(
|
||||
message = tagger.message,
|
||||
geohash = geoHash,
|
||||
replyingTo = originalNote,
|
||||
directMentionsUsers = tagger.directMentionsUsers,
|
||||
directMentionsNotes = tagger.directMentionsNotes,
|
||||
nip94attachments = usedAttachments,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
relayList = relayList,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (originalNote?.channelHex() != null) {
|
||||
if (originalNote is AddressableEvent && originalNote?.address() != null) {
|
||||
account?.sendLiveMessage(
|
||||
message = tagger.message,
|
||||
@@ -836,7 +872,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: Int,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
@@ -853,7 +889,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.isNip95) {
|
||||
if (server.type == ServerType.NIP95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
@@ -866,7 +902,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
context,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (server.type == ServerType.NIP96) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
@@ -877,13 +913,52 @@ open class NewPostViewModel : ViewModel() {
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
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
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
} else if (server.type == ServerType.Blossom) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
@@ -953,6 +1028,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsToAddGeoHash = false
|
||||
wantsExclusiveGeoPost = false
|
||||
forwardZapTo = Split()
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
@@ -981,6 +1057,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
draftTextChanges.trySend("")
|
||||
}
|
||||
|
||||
open fun addToMessage(it: String) {
|
||||
updateMessage(TextFieldValue(message.text + " " + it))
|
||||
}
|
||||
|
||||
open fun updateMessage(it: TextFieldValue) {
|
||||
message = it
|
||||
urlPreview = findUrlInMessage()
|
||||
@@ -998,7 +1078,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions =
|
||||
LocalCache
|
||||
.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.findUsersStartingWith(lastWord.removePrefix("@"), account)
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
|
||||
.reversed()
|
||||
}
|
||||
@@ -1027,7 +1107,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions =
|
||||
LocalCache
|
||||
.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.findUsersStartingWith(lastWord.removePrefix("@"), account)
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
|
||||
.reversed()
|
||||
}
|
||||
@@ -1055,7 +1135,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions =
|
||||
LocalCache
|
||||
.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.findUsersStartingWith(lastWord.removePrefix("@"), account)
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ account?.isFollowing(it) },
|
||||
@@ -1144,7 +1224,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
contentToAddUrl == null
|
||||
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
uploadingResult: MediaUploadResult,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
@@ -1152,30 +1232,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
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)
|
||||
val remoteMimeType =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val originalHash =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val dim =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "dim" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val magnet =
|
||||
uploadingResult.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
if (uploadingResult.url.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
@@ -1184,16 +1241,16 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
forceProxy = forceProxy(imageUrl),
|
||||
fileUrl = uploadingResult.url,
|
||||
mimeType = uploadingResult.type ?: localContentType,
|
||||
dimPrecomputed = uploadingResult.dimension,
|
||||
forceProxy = forceProxy(uploadingResult.url),
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
|
||||
isUploadingImage = false
|
||||
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
|
||||
|
||||
message = message.insertUrlAtCursor(imageUrl)
|
||||
message = message.insertUrlAtCursor(uploadingResult.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
saveDraft()
|
||||
}
|
||||
@@ -1257,29 +1314,17 @@ open class NewPostViewModel : ViewModel() {
|
||||
contentToAddUrl = uri
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun startLocation(context: Context) {
|
||||
locUtil = LocationUtil(context)
|
||||
locUtil?.let {
|
||||
location =
|
||||
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
|
||||
saveDraft()
|
||||
fun locationFlow(): StateFlow<LocationState.LocationResult> {
|
||||
if (location == null) {
|
||||
location = Amethyst.instance.locationManager.geohashStateFlow
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
|
||||
}
|
||||
|
||||
fun stopLocation() {
|
||||
viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() }
|
||||
location = null
|
||||
locUtil = null
|
||||
return location!!
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
|
||||
viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() }
|
||||
location = null
|
||||
locUtil = null
|
||||
}
|
||||
|
||||
fun toggleNIP04And24() {
|
||||
@@ -1382,7 +1427,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" }
|
||||
}
|
||||
}
|
||||
elementList.removeLast()
|
||||
elementList.removeAt(elementList.size - 1)
|
||||
val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) }
|
||||
this.clear()
|
||||
this.putAll(newEntries)
|
||||
|
||||
@@ -46,6 +46,8 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@@ -187,6 +189,22 @@ fun NewUserMetadataView(
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.pronouns)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.pronouns.value,
|
||||
onValueChange = { postViewModel.pronouns.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "they/them, ...",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.website_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+35
-17
@@ -29,7 +29,9 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -51,6 +53,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
val banner = mutableStateOf("")
|
||||
|
||||
val website = mutableStateOf("")
|
||||
val pronouns = mutableStateOf("")
|
||||
val nip05 = mutableStateOf("")
|
||||
val lnAddress = mutableStateOf("")
|
||||
val lnURL = mutableStateOf("")
|
||||
@@ -72,6 +75,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
picture.value = it.info?.picture ?: ""
|
||||
banner.value = it.info?.banner ?: ""
|
||||
website.value = it.info?.website ?: ""
|
||||
pronouns.value = it.info?.pronouns ?: ""
|
||||
nip05.value = it.info?.nip05 ?: ""
|
||||
lnAddress.value = it.info?.lud16 ?: ""
|
||||
lnURL.value = it.info?.lud06 ?: ""
|
||||
@@ -99,6 +103,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
picture = picture.value,
|
||||
banner = banner.value,
|
||||
website = website.value,
|
||||
pronouns = pronouns.value,
|
||||
about = about.value,
|
||||
nip05 = nip05.value,
|
||||
lnAddress = lnAddress.value,
|
||||
@@ -178,25 +183,38 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.settings.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.settings.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
} else {
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.settings.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
|
||||
val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
|
||||
if (url != null) {
|
||||
if (result.url != null) {
|
||||
onUploading(false)
|
||||
onUploaded(url)
|
||||
onUploaded(result.url)
|
||||
} else {
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
|
||||
@@ -50,6 +50,8 @@ 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.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
|
||||
|
||||
@@ -22,9 +22,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.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
@@ -55,11 +56,7 @@ 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
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -69,7 +66,6 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun UploadFromGallery(
|
||||
isUploading: Boolean,
|
||||
@@ -77,32 +73,19 @@ fun UploadFromGallery(
|
||||
modifier: Modifier,
|
||||
onImageChosen: (Uri) -> Unit,
|
||||
) {
|
||||
val cameraPermissionState =
|
||||
rememberPermissionState(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
android.Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
var showGallerySelect by remember { mutableStateOf(false) }
|
||||
if (showGallerySelect) {
|
||||
GallerySelect(
|
||||
onImageUri = { uri ->
|
||||
showGallerySelect = false
|
||||
if (uri != null) {
|
||||
onImageChosen(uri)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
var showGallerySelect by remember { mutableStateOf(false) }
|
||||
if (showGallerySelect) {
|
||||
GallerySelect(
|
||||
onImageUri = { uri ->
|
||||
showGallerySelect = false
|
||||
if (uri != null) {
|
||||
onImageChosen(uri)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true }
|
||||
} else {
|
||||
UploadBoxButton(isUploading, tint, modifier) { cameraPermissionState.launchPermissionRequest() }
|
||||
}
|
||||
|
||||
UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -183,6 +166,7 @@ fun LoadingAnimation(
|
||||
easing = LinearEasing,
|
||||
),
|
||||
),
|
||||
label = "UploadGalleryUploadingAnimation",
|
||||
)
|
||||
|
||||
CircularProgressIndicator(
|
||||
@@ -203,10 +187,10 @@ fun LoadingAnimation(
|
||||
|
||||
@Composable
|
||||
fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) {
|
||||
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
|
||||
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = GetMediaActivityResultContract(),
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
onResult = { uri: Uri? ->
|
||||
onImageUri(uri)
|
||||
hasLaunched.set(false)
|
||||
@@ -217,7 +201,7 @@ fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) {
|
||||
fun LaunchGallery() {
|
||||
SideEffect {
|
||||
if (!hasLaunched.getAndSet(true)) {
|
||||
launcher.launch("*/*")
|
||||
launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+77
-22
@@ -54,13 +54,14 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.SettingsCategory
|
||||
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.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -73,11 +74,15 @@ fun MediaServersListView(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val mediaServersViewModel: MediaServersViewModel = viewModel()
|
||||
val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle()
|
||||
val nip96ServersViewModel: NIP96ServersViewModel = viewModel()
|
||||
val nip96ServersState by nip96ServersViewModel.fileServers.collectAsStateWithLifecycle()
|
||||
|
||||
val blossomServersViewModel: BlossomServersViewModel = viewModel()
|
||||
val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
mediaServersViewModel.load(accountViewModel.account)
|
||||
nip96ServersViewModel.load(accountViewModel.account)
|
||||
blossomServersViewModel.load(accountViewModel.account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
@@ -102,7 +107,8 @@ fun MediaServersListView(
|
||||
navigationIcon = {
|
||||
CloseButton(
|
||||
onPress = {
|
||||
mediaServersViewModel.refresh()
|
||||
nip96ServersViewModel.refresh()
|
||||
blossomServersViewModel.refresh()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
@@ -110,7 +116,8 @@ fun MediaServersListView(
|
||||
actions = {
|
||||
SaveButton(
|
||||
onPost = {
|
||||
mediaServersViewModel.saveFileServers()
|
||||
nip96ServersViewModel.saveFileServers()
|
||||
blossomServersViewModel.saveFileServers()
|
||||
onClose()
|
||||
},
|
||||
isActive = true,
|
||||
@@ -148,17 +155,48 @@ fun MediaServersListView(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.media_servers_nip96_section),
|
||||
stringRes(R.string.media_servers_nip96_explainer),
|
||||
Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
renderMediaServerList(
|
||||
mediaServersState = mediaServersState,
|
||||
mediaServersState = nip96ServersState,
|
||||
keyType = "nip96",
|
||||
editLabel = R.string.add_a_nip96_server,
|
||||
emptyLabel = R.string.no_nip96_server_message,
|
||||
onAddServer = { server ->
|
||||
mediaServersViewModel.addServer(server)
|
||||
nip96ServersViewModel.addServer(server)
|
||||
},
|
||||
onDeleteServer = {
|
||||
mediaServersViewModel.removeServer(serverUrl = it)
|
||||
nip96ServersViewModel.removeServer(serverUrl = it)
|
||||
},
|
||||
)
|
||||
|
||||
Nip96MediaServers.DEFAULT.let {
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.media_servers_blossom_section),
|
||||
stringRes(R.string.media_servers_blossom_explainer),
|
||||
)
|
||||
}
|
||||
|
||||
renderMediaServerList(
|
||||
mediaServersState = blossomServersState,
|
||||
keyType = "blossom",
|
||||
editLabel = R.string.add_a_blossom_server,
|
||||
emptyLabel = R.string.no_blossom_server_message,
|
||||
onAddServer = { server ->
|
||||
blossomServersViewModel.addServer(server)
|
||||
},
|
||||
onDeleteServer = {
|
||||
blossomServersViewModel.removeServer(serverUrl = it)
|
||||
},
|
||||
)
|
||||
|
||||
DEFAULT_MEDIA_SERVERS.let {
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
title = stringRes(id = R.string.built_in_media_servers_title),
|
||||
@@ -166,7 +204,13 @@ fun MediaServersListView(
|
||||
action = {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
mediaServersViewModel.addServerList(it.map { s -> s.baseUrl })
|
||||
nip96ServersViewModel.addServerList(
|
||||
it.mapNotNull { s -> if (s.type == ServerType.NIP96) s.baseUrl else null },
|
||||
)
|
||||
|
||||
blossomServersViewModel.addServerList(
|
||||
it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.use_default_servers))
|
||||
@@ -176,19 +220,27 @@ fun MediaServersListView(
|
||||
}
|
||||
itemsIndexed(
|
||||
it,
|
||||
key = { index: Int, server: Nip96MediaServers.ServerName ->
|
||||
server.baseUrl
|
||||
key = { index: Int, server: ServerName ->
|
||||
"Proposed" + server.baseUrl
|
||||
},
|
||||
) { index, server ->
|
||||
MediaServerEntry(
|
||||
serverEntry = server,
|
||||
isAmethystDefault = true,
|
||||
onAddOrDelete = { serverUrl ->
|
||||
mediaServersViewModel.addServer(serverUrl)
|
||||
if (server.type == ServerType.NIP96) {
|
||||
nip96ServersViewModel.addServer(serverUrl)
|
||||
} else if (server.type == ServerType.Blossom) {
|
||||
blossomServersViewModel.addServer(serverUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(DoubleHorzSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,22 +248,25 @@ fun MediaServersListView(
|
||||
}
|
||||
|
||||
fun LazyListScope.renderMediaServerList(
|
||||
mediaServersState: List<Nip96MediaServers.ServerName>,
|
||||
mediaServersState: List<ServerName>,
|
||||
keyType: String,
|
||||
editLabel: Int,
|
||||
emptyLabel: Int,
|
||||
onAddServer: (String) -> Unit,
|
||||
onDeleteServer: (String) -> Unit,
|
||||
) {
|
||||
if (mediaServersState.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringRes(id = R.string.no_media_server_message),
|
||||
text = stringRes(id = emptyLabel),
|
||||
modifier = DoubleVertPadding,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
mediaServersState,
|
||||
key = { index: Int, server: Nip96MediaServers.ServerName ->
|
||||
server.baseUrl
|
||||
key = { index: Int, server: ServerName ->
|
||||
keyType + server.baseUrl
|
||||
},
|
||||
) { index, entry ->
|
||||
MediaServerEntry(
|
||||
@@ -225,7 +280,7 @@ fun LazyListScope.renderMediaServerList(
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
MediaServerEditField {
|
||||
MediaServerEditField(editLabel) {
|
||||
onAddServer(it)
|
||||
}
|
||||
}
|
||||
@@ -234,7 +289,7 @@ fun LazyListScope.renderMediaServerList(
|
||||
@Composable
|
||||
fun MediaServerEntry(
|
||||
modifier: Modifier = Modifier,
|
||||
serverEntry: Nip96MediaServers.ServerName,
|
||||
serverEntry: ServerName,
|
||||
isAmethystDefault: Boolean = false,
|
||||
onAddOrDelete: (serverUrl: String) -> Unit,
|
||||
) {
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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.mediaServers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.czeal.rfc3986.URIReference
|
||||
|
||||
class BlossomServersViewModel : ViewModel() {
|
||||
lateinit var account: Account
|
||||
|
||||
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
|
||||
val fileServers = _fileServers.asStateFlow()
|
||||
private var isModified = false
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
isModified = false
|
||||
_fileServers.update {
|
||||
val obtainedFileServers = obtainFileServers() ?: emptyList()
|
||||
obtainedFileServers.mapNotNull { serverUrl ->
|
||||
try {
|
||||
ServerName(
|
||||
URIReference.parse(serverUrl).host.value,
|
||||
serverUrl,
|
||||
ServerType.Blossom,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.d("MediaServersViewModel", "Invalid URL in Blossom server list")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addServerList(serverList: List<String>) {
|
||||
serverList.forEach { serverUrl ->
|
||||
addServer(serverUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun addServer(serverUrl: String) {
|
||||
val normalizedUrl =
|
||||
try {
|
||||
URIReference.parse(serverUrl.trim()).normalize().toString()
|
||||
} catch (e: Exception) {
|
||||
serverUrl
|
||||
}
|
||||
val serverNameReference =
|
||||
try {
|
||||
URIReference.parse(normalizedUrl).host.value
|
||||
} catch (e: Exception) {
|
||||
normalizedUrl
|
||||
}
|
||||
val serverRef =
|
||||
ServerName(
|
||||
serverNameReference,
|
||||
normalizedUrl,
|
||||
ServerType.Blossom,
|
||||
)
|
||||
if (_fileServers.value.contains(serverRef)) {
|
||||
return
|
||||
} else {
|
||||
_fileServers.update {
|
||||
it.plus(serverRef)
|
||||
}
|
||||
}
|
||||
isModified = true
|
||||
}
|
||||
|
||||
fun removeServer(
|
||||
name: String = "",
|
||||
serverUrl: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
|
||||
_fileServers.update {
|
||||
it.minus(
|
||||
ServerName(serverName, serverUrl, ServerType.Blossom),
|
||||
)
|
||||
}
|
||||
isModified = true
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllServers() {
|
||||
_fileServers.update { emptyList() }
|
||||
isModified = true
|
||||
}
|
||||
|
||||
fun saveFileServers() {
|
||||
if (isModified) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val serverList = _fileServers.value.map { it.baseUrl }
|
||||
account.sendBlossomServersList(serverList)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obtainFileServers(): List<String>? = account.getBlossomServersList()?.servers()
|
||||
}
|
||||
+2
-1
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.encoders.HttpUrlFormatter
|
||||
|
||||
@Composable
|
||||
fun MediaServerEditField(
|
||||
label: Int = R.string.add_a_nip96_server,
|
||||
modifier: Modifier = Modifier,
|
||||
onAddServer: (String) -> Unit,
|
||||
) {
|
||||
@@ -63,7 +64,7 @@ fun MediaServerEditField(
|
||||
),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.add_a_nip96_server)) },
|
||||
label = { Text(text = stringRes(label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
|
||||
+9
-10
@@ -24,7 +24,6 @@ import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -32,10 +31,10 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.czeal.rfc3986.URIReference
|
||||
|
||||
class MediaServersViewModel : ViewModel() {
|
||||
class NIP96ServersViewModel : ViewModel() {
|
||||
lateinit var account: Account
|
||||
|
||||
private val _fileServers = MutableStateFlow<List<Nip96MediaServers.ServerName>>(emptyList())
|
||||
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
|
||||
val fileServers = _fileServers.asStateFlow()
|
||||
private var isModified = false
|
||||
|
||||
@@ -50,11 +49,11 @@ class MediaServersViewModel : ViewModel() {
|
||||
val obtainedFileServers = obtainFileServers() ?: emptyList()
|
||||
obtainedFileServers.mapNotNull { serverUrl ->
|
||||
try {
|
||||
Nip96MediaServers
|
||||
.ServerName(
|
||||
URIReference.parse(serverUrl).host.value,
|
||||
serverUrl,
|
||||
)
|
||||
ServerName(
|
||||
URIReference.parse(serverUrl).host.value,
|
||||
serverUrl,
|
||||
ServerType.NIP96,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list")
|
||||
null
|
||||
@@ -82,7 +81,7 @@ class MediaServersViewModel : ViewModel() {
|
||||
} catch (e: Exception) {
|
||||
normalizedUrl
|
||||
}
|
||||
val serverRef = Nip96MediaServers.ServerName(serverNameReference, normalizedUrl)
|
||||
val serverRef = ServerName(serverNameReference, normalizedUrl, ServerType.NIP96)
|
||||
if (_fileServers.value.contains(serverRef)) {
|
||||
return
|
||||
} else {
|
||||
@@ -101,7 +100,7 @@ class MediaServersViewModel : ViewModel() {
|
||||
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
|
||||
_fileServers.update {
|
||||
it.minus(
|
||||
Nip96MediaServers.ServerName(serverName, serverUrl),
|
||||
ServerName(serverName, serverUrl, ServerType.NIP96),
|
||||
)
|
||||
}
|
||||
isModified = true
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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.mediaServers
|
||||
|
||||
data class ServerName(
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val type: ServerType = ServerType.NIP96,
|
||||
)
|
||||
|
||||
enum class ServerType {
|
||||
NIP96,
|
||||
NIP95,
|
||||
Blossom,
|
||||
}
|
||||
|
||||
val DEFAULT_MEDIA_SERVERS: List<ServerName> =
|
||||
listOf(
|
||||
ServerName("Nostr.Build", "https://nostr.build", ServerType.NIP96),
|
||||
ServerName("NostrCheck.me", "https://nostrcheck.me", ServerType.NIP96),
|
||||
ServerName("NostPic", "https://nostpic.com", ServerType.NIP96),
|
||||
ServerName("Sovbit", "https://files.sovbit.host", ServerType.NIP96),
|
||||
ServerName("Void.cat", "https://void.cat", ServerType.NIP96),
|
||||
ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom),
|
||||
ServerName("NostrCheck.me (Blossom)", "https://cdn.nostrcheck.me", ServerType.Blossom),
|
||||
ServerName("Primal", "https://blossom.primal.net", ServerType.Blossom),
|
||||
)
|
||||
+27
-5
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -42,14 +43,17 @@ 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.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.ammolite.relays.RelayStat
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -66,6 +70,7 @@ fun AddDMRelayListDialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
SetDialogToEdgeToEdge()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -114,7 +119,7 @@ fun AddDMRelayListDialog(
|
||||
),
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Explanation()
|
||||
Explanation(postViewModel)
|
||||
|
||||
DMRelayList(postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
@@ -123,7 +128,7 @@ fun AddDMRelayListDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Explanation() {
|
||||
private fun Explanation(postViewModel: DMRelayListViewModel) {
|
||||
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
@@ -133,8 +138,25 @@ private fun Explanation() {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
Text(
|
||||
text = stringRes(id = R.string.dm_relays_not_found_examples),
|
||||
text = stringRes(id = R.string.dm_relays_not_found_examples2),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
ResetDMRelaysLonger(postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResetDMRelaysLonger(postViewModel: DMRelayListViewModel) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
DefaultDMRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.default_relays_longer))
|
||||
}
|
||||
}
|
||||
|
||||
+26
-4
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -42,14 +43,17 @@ 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.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.ammolite.relays.RelayStat
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -66,6 +70,7 @@ fun AddSearchRelayListDialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
SetDialogToEdgeToEdge()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -114,7 +119,7 @@ fun AddSearchRelayListDialog(
|
||||
),
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Explanation()
|
||||
Explanation(postViewModel)
|
||||
|
||||
SearchRelayList(postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
@@ -123,7 +128,7 @@ fun AddSearchRelayListDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Explanation() {
|
||||
private fun Explanation(postViewModel: SearchRelayListViewModel) {
|
||||
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
@@ -135,6 +140,23 @@ private fun Explanation() {
|
||||
Text(
|
||||
text = stringRes(id = R.string.search_relays_not_found_examples),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
ResetSearchRelaysLonger(postViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResetSearchRelaysLonger(postViewModel: SearchRelayListViewModel) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
DefaultSearchRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.default_relays_longer))
|
||||
}
|
||||
}
|
||||
|
||||
+96
-79
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -49,11 +50,13 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.model.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
|
||||
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.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer
|
||||
@@ -171,91 +174,92 @@ fun MappedAllRelayListView(
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
16.dp,
|
||||
pad.calculateTopPadding(),
|
||||
16.dp,
|
||||
pad.calculateBottomPadding(),
|
||||
),
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = pad.calculateTopPadding(),
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
).consumeWindowInsets(pad),
|
||||
) {
|
||||
LazyColumn(contentPadding = FeedPadding) {
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.public_home_section),
|
||||
stringRes(R.string.public_home_section_explainer),
|
||||
Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.public_notif_section),
|
||||
stringRes(R.string.public_notif_section_explainer),
|
||||
)
|
||||
}
|
||||
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
stringRes(R.string.private_inbox_section),
|
||||
stringRes(R.string.private_inbox_section_explainer),
|
||||
action = {
|
||||
ResetDMRelays(dmViewModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.private_outbox_section),
|
||||
stringRes(R.string.private_outbox_section_explainer),
|
||||
)
|
||||
}
|
||||
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
stringRes(R.string.search_section),
|
||||
stringRes(R.string.search_section_explainer),
|
||||
action = {
|
||||
ResetSearchRelays(searchViewModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.local_section),
|
||||
stringRes(R.string.local_section_explainer),
|
||||
)
|
||||
}
|
||||
renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
stringRes(R.string.kind_3_section),
|
||||
stringRes(R.string.kind_3_section_description),
|
||||
action = {
|
||||
ResetKind3Relays(kind3ViewModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd)
|
||||
|
||||
if (kind3Proposals.isNotEmpty()) {
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.public_home_section),
|
||||
stringRes(R.string.public_home_section_explainer),
|
||||
Modifier.padding(bottom = 8.dp),
|
||||
stringRes(R.string.kind_3_recommended_section),
|
||||
stringRes(R.string.kind_3_recommended_section_description),
|
||||
)
|
||||
}
|
||||
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.public_notif_section),
|
||||
stringRes(R.string.public_notif_section_explainer),
|
||||
)
|
||||
}
|
||||
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.private_inbox_section),
|
||||
stringRes(R.string.private_inbox_section_explainer),
|
||||
)
|
||||
}
|
||||
renderDMItems(dmFeedState, dmViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.private_outbox_section),
|
||||
stringRes(R.string.private_outbox_section_explainer),
|
||||
)
|
||||
}
|
||||
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
stringRes(R.string.search_section),
|
||||
stringRes(R.string.search_section_explainer),
|
||||
action = {
|
||||
ResetSearchRelays(searchViewModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.local_section),
|
||||
stringRes(R.string.local_section_explainer),
|
||||
)
|
||||
}
|
||||
renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
stringRes(R.string.kind_3_section),
|
||||
stringRes(R.string.kind_3_section_description),
|
||||
action = {
|
||||
ResetKind3Relays(kind3ViewModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd)
|
||||
|
||||
if (kind3Proposals.isNotEmpty()) {
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringRes(R.string.kind_3_recommended_section),
|
||||
stringRes(R.string.kind_3_recommended_section_description),
|
||||
)
|
||||
}
|
||||
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav)
|
||||
}
|
||||
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +284,20 @@ fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
|
||||
DefaultSearchRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.default_relays))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResetDMRelays(postViewModel: DMRelayListViewModel) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
DefaultDMRelayList.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
val proposed =
|
||||
RelayListRecommendationProcessor
|
||||
.reliableRelaySetFor(
|
||||
account.liveKind3Follows.value.users.mapNotNull {
|
||||
account.liveKind3Follows.value.authors.mapNotNull {
|
||||
account.getNIP65RelayList(it)
|
||||
},
|
||||
relayUrlsToIgnore =
|
||||
|
||||
+3
-1
@@ -50,10 +50,10 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
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.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav
|
||||
@@ -61,6 +61,7 @@ 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.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
@@ -104,6 +105,7 @@ fun RelayInformationDialog(
|
||||
dismissOnClickOutside = false,
|
||||
),
|
||||
) {
|
||||
SetDialogToEdgeToEdge()
|
||||
Surface {
|
||||
val color =
|
||||
remember {
|
||||
|
||||
@@ -37,11 +37,9 @@ fun ClickableNoteTag(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val route = routeFor(baseNote, accountViewModel.userProfile())
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
|
||||
onClick = { nav.nav("Note/${baseNote.idHex}") },
|
||||
onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.ui.theme.isLight
|
||||
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
|
||||
|
||||
+1
-3
@@ -97,9 +97,7 @@ fun SensitivityWarning(
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val accountState =
|
||||
accountViewModel.account.settings.showSensitiveContent
|
||||
.collectAsStateWithLifecycle()
|
||||
val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
|
||||
|
||||
var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) }
|
||||
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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.components
|
||||
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
|
||||
@Composable
|
||||
fun SetDialogToEdgeToEdge() {
|
||||
val activityWindow = getActivityWindow()
|
||||
val dialogWindow = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
val parentView = LocalView.current.parent as View
|
||||
SideEffect {
|
||||
if (activityWindow != null && dialogWindow != null) {
|
||||
val attributes = WindowManager.LayoutParams()
|
||||
attributes.copyFrom(activityWindow.attributes)
|
||||
attributes.type = dialogWindow.attributes.type
|
||||
dialogWindow.attributes = attributes
|
||||
parentView.layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
activityWindow.decorView.width,
|
||||
activityWindow.decorView.height,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@@ -120,6 +120,7 @@ import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -211,7 +212,7 @@ fun VideoView(
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
dimensions: String? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
@@ -242,7 +243,7 @@ fun VideoView(
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
dimensions: String? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
@@ -260,7 +261,7 @@ fun VideoView(
|
||||
}
|
||||
|
||||
if (blurhash == null) {
|
||||
val ratio = aspectRatio(dimensions)
|
||||
val ratio = dimensions?.aspectRatio()
|
||||
val modifier =
|
||||
if (ratio != null && automaticallyStartPlayback.value) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
@@ -294,7 +295,7 @@ fun VideoView(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val ratio = aspectRatio(dimensions)
|
||||
val ratio = dimensions?.aspectRatio()
|
||||
|
||||
val modifier =
|
||||
if (ratio != null) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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.components
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import com.vitorpamplona.amethyst.ui.navigation.getActivity
|
||||
|
||||
// Window utils
|
||||
@Composable
|
||||
fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
|
||||
@Composable
|
||||
fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow()
|
||||
|
||||
private tailrec fun Context.getActivityWindow(): Window? =
|
||||
when (this) {
|
||||
is Activity -> window
|
||||
is ContextWrapper -> baseContext.getActivityWindow()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getActivity(): Activity? = LocalView.current.context.getActivity()
|
||||
|
||||
private tailrec fun Context.getActivity(): Activity? =
|
||||
when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.getActivity()
|
||||
else -> null
|
||||
}
|
||||
+85
-83
@@ -29,7 +29,6 @@ import android.widget.FrameLayout
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -67,8 +66,7 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
@@ -104,8 +102,6 @@ fun ZoomableImageDialog(
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
@@ -115,7 +111,6 @@ fun ZoomableImageDialog(
|
||||
),
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val insets = ViewCompat.getRootWindowInsets(view)
|
||||
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
println("This Log only exists to force orientation listener $orientation")
|
||||
@@ -151,7 +146,7 @@ fun ZoomableImageDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class)
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
private fun DialogContent(
|
||||
allImages: ImmutableList<BaseMediaContent>,
|
||||
imageUrl: BaseMediaContent,
|
||||
@@ -296,7 +291,7 @@ private fun saveMediaToGallery(
|
||||
localContext: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val isImage = content is MediaUrlImage && content is MediaLocalImage
|
||||
val isImage = content is MediaUrlImage || content is MediaLocalImage
|
||||
|
||||
val success = if (isImage) R.string.image_saved_to_the_gallery else R.string.video_saved_to_the_gallery
|
||||
val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video
|
||||
@@ -329,8 +324,8 @@ private fun saveMediaToGallery(
|
||||
onSuccess = {
|
||||
accountViewModel.toast(success, success)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(failure, null, it)
|
||||
onError = { innerIt ->
|
||||
accountViewModel.toast(failure, null, innerIt)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -338,7 +333,6 @@ private fun saveMediaToGallery(
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun InlineCarrousel(
|
||||
allImages: ImmutableList<String>,
|
||||
imageUrl: String,
|
||||
@@ -385,7 +379,7 @@ private fun RenderImageOrVideo(
|
||||
onToggleControllerVisibility: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
|
||||
val automaticallyStartPlayback = remember { mutableStateOf(true) }
|
||||
val contentScale =
|
||||
if (isFiniteHeight) {
|
||||
ContentScale.Fit
|
||||
@@ -394,81 +388,41 @@ private fun RenderImageOrVideo(
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
if (content is MediaUrlImage) {
|
||||
val mainModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
},
|
||||
)
|
||||
when (content) {
|
||||
is MediaUrlImage -> {
|
||||
val mainModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
UrlImageView(
|
||||
content = content,
|
||||
contentScale = contentScale,
|
||||
mainImageModifier = mainModifier,
|
||||
loadedImageModifier = Modifier.fillMaxWidth(),
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
} else if (content is MediaUrlVideo) {
|
||||
val borderModifier =
|
||||
if (roundedCorner) {
|
||||
MaterialTheme.colorScheme.imageModifier
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
UrlImageView(
|
||||
content = content,
|
||||
contentScale = contentScale,
|
||||
mainImageModifier = mainModifier,
|
||||
loadedImageModifier = Modifier.fillMaxWidth(),
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
}
|
||||
|
||||
VideoViewInner(
|
||||
videoUri = content.url,
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
borderModifier = borderModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else if (content is MediaLocalImage) {
|
||||
val mainModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
},
|
||||
)
|
||||
is MediaUrlVideo -> {
|
||||
val borderModifier =
|
||||
if (roundedCorner) {
|
||||
MaterialTheme.colorScheme.imageModifier
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
LocalImageView(
|
||||
content = content,
|
||||
contentScale = contentScale,
|
||||
mainImageModifier = mainModifier,
|
||||
loadedImageModifier = Modifier.fillMaxWidth(),
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
} else if (content is MediaLocalVideo) {
|
||||
val borderModifier =
|
||||
if (roundedCorner) {
|
||||
MaterialTheme.colorScheme.imageModifier
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
content.localFile?.let {
|
||||
VideoViewInner(
|
||||
videoUri = it.toUri().toString(),
|
||||
videoUri = content.url,
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
@@ -480,6 +434,54 @@ private fun RenderImageOrVideo(
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
is MediaLocalImage -> {
|
||||
val mainModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
LocalImageView(
|
||||
content = content,
|
||||
contentScale = contentScale,
|
||||
mainImageModifier = mainModifier,
|
||||
loadedImageModifier = Modifier.fillMaxWidth(),
|
||||
controllerVisible = controllerVisible,
|
||||
accountViewModel = accountViewModel,
|
||||
alwayShowImage = true,
|
||||
)
|
||||
}
|
||||
|
||||
is MediaLocalVideo -> {
|
||||
val borderModifier =
|
||||
if (roundedCorner) {
|
||||
MaterialTheme.colorScheme.imageModifier
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
content.localFile?.let {
|
||||
VideoViewInner(
|
||||
videoUri = it.toUri().toString(),
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
borderModifier = borderModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-82
@@ -20,11 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.util.Log
|
||||
import android.view.Window
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -44,6 +40,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -61,14 +58,13 @@ import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.net.toUri
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.compose.SubcomposeAsyncImageContent
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -79,7 +75,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||
import com.vitorpamplona.amethyst.service.Blurhash
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
@@ -102,12 +98,11 @@ import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -311,7 +306,8 @@ fun LocalImageView(
|
||||
contentScale = contentScale,
|
||||
modifier = mainImageModifier,
|
||||
) {
|
||||
when (painter.state) {
|
||||
val state by painter.state.collectAsState()
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading,
|
||||
-> {
|
||||
if (content.blurhash != null) {
|
||||
@@ -392,7 +388,7 @@ fun UrlImageView(
|
||||
accountViewModel: AccountViewModel,
|
||||
alwayShowImage: Boolean = false,
|
||||
) {
|
||||
val ratio = remember(content) { aspectRatio(content.dim) }
|
||||
val ratio = content.dim?.aspectRatio()
|
||||
|
||||
val showImage =
|
||||
remember {
|
||||
@@ -409,7 +405,8 @@ fun UrlImageView(
|
||||
contentScale = contentScale,
|
||||
modifier = mainImageModifier,
|
||||
) {
|
||||
when (painter.state) {
|
||||
val state by painter.state.collectAsState()
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading,
|
||||
-> {
|
||||
if (content.blurhash != null) {
|
||||
@@ -515,6 +512,8 @@ fun ImageUrlWithDownloadButton(
|
||||
text = annotatedTermsString,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -554,44 +553,14 @@ fun ShowHash(content: MediaUrlContent) {
|
||||
verifiedHash?.let { HashVerificationSymbol(it) }
|
||||
}
|
||||
|
||||
fun aspectRatio(dim: String?): Float? {
|
||||
fun aspectRatio(dim: Dimension?): Float? {
|
||||
if (dim == null) return null
|
||||
if (dim == "0x0") return null
|
||||
|
||||
val parts = dim.split("x")
|
||||
if (parts.size != 2) return null
|
||||
|
||||
return try {
|
||||
val width = parts[0].toFloat()
|
||||
val height = parts[1].toFloat()
|
||||
|
||||
if (width < 0.1 || height < 0.1) {
|
||||
null
|
||||
} else {
|
||||
width / height
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
return dim.width.toFloat() / dim.height.toFloat()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
||||
var cnt by remember { mutableStateOf<BaseMediaContent?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
delay(200)
|
||||
cnt = content
|
||||
}
|
||||
}
|
||||
|
||||
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
@@ -638,6 +607,8 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
|
||||
text = annotatedTermsString,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -662,17 +633,8 @@ fun DisplayBlurHash(
|
||||
) {
|
||||
if (blurhash == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val model =
|
||||
remember {
|
||||
BlurHashRequester.imageRequest(
|
||||
context,
|
||||
blurhash,
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = model,
|
||||
model = Blurhash(blurhash),
|
||||
contentDescription = description,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier,
|
||||
@@ -721,7 +683,7 @@ fun ShareImageAction(
|
||||
videoUri: String?,
|
||||
postNostrUri: String?,
|
||||
blurhash: String?,
|
||||
dim: String?,
|
||||
dim: Dimension?,
|
||||
hash: String?,
|
||||
mimeType: String?,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -771,7 +733,6 @@ fun ShareImageAction(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
@@ -820,25 +781,3 @@ private fun HashVerificationSymbol(verifiedHash: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Window utils
|
||||
@Composable
|
||||
fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
|
||||
@Composable fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow()
|
||||
|
||||
private tailrec fun Context.getActivityWindow(): Window? =
|
||||
when (this) {
|
||||
is Activity -> window
|
||||
is ContextWrapper -> baseContext.getActivityWindow()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable fun getActivity(): Activity? = LocalView.current.context.getActivity()
|
||||
|
||||
private tailrec fun Context.getActivity(): Activity? =
|
||||
when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.getActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
+2
-2
@@ -85,7 +85,7 @@ class MarkdownMediaRenderer(
|
||||
) {
|
||||
if (canPreview) {
|
||||
val content =
|
||||
parser.parseMediaUrl(
|
||||
parser.createMediaContent(
|
||||
fullUrl = uri,
|
||||
eventTags = tags ?: EmptyTagList,
|
||||
description = title?.ifEmpty { null } ?: startOfText,
|
||||
@@ -109,7 +109,7 @@ class MarkdownMediaRenderer(
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
|
||||
val content = parser.createMediaContent(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
|
||||
|
||||
if (canPreview) {
|
||||
if (content != null) {
|
||||
|
||||
@@ -67,7 +67,7 @@ open class DiscoverLiveFeedFilter(
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
val followingKeySet =
|
||||
account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users
|
||||
account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors
|
||||
|
||||
val counter = ParticipantListBuilder()
|
||||
val participantCounts =
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AROUND_ME
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
@@ -33,7 +35,8 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
class FilterByListParams(
|
||||
val isGlobal: Boolean,
|
||||
val isHiddenList: Boolean,
|
||||
val followLists: Account.LiveFollowLists?,
|
||||
val isAroundMe: Boolean,
|
||||
val followLists: Account.LiveFollowList?,
|
||||
val hiddenLists: Account.LiveHiddenUsers,
|
||||
val now: Long = TimeUtils.oneMinuteFromNow(),
|
||||
) {
|
||||
@@ -43,24 +46,30 @@ class FilterByListParams(
|
||||
|
||||
fun isEventInList(noteEvent: Event): Boolean {
|
||||
if (followLists == null) return false
|
||||
if (isAroundMe && followLists.geotags.isEmpty() == true) return false
|
||||
|
||||
return if (noteEvent is LiveActivitiesEvent) {
|
||||
noteEvent.participantsIntersect(followLists.users) ||
|
||||
noteEvent.participantsIntersect(followLists.authors) ||
|
||||
noteEvent.isTaggedHashes(followLists.hashtags) ||
|
||||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
|
||||
noteEvent.isTaggedAddressableNotes(followLists.communities)
|
||||
noteEvent.isTaggedAddressableNotes(followLists.addresses)
|
||||
} else if (noteEvent is CommentEvent) {
|
||||
// ignore follows and checks only the root scope
|
||||
noteEvent.isTaggedHashes(followLists.hashtags) ||
|
||||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
|
||||
noteEvent.isTaggedAddressableNotes(followLists.addresses)
|
||||
} else {
|
||||
noteEvent.pubKey in followLists.users ||
|
||||
noteEvent.pubKey in followLists.authors ||
|
||||
noteEvent.isTaggedHashes(followLists.hashtags) ||
|
||||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
|
||||
noteEvent.isTaggedAddressableNotes(followLists.communities)
|
||||
noteEvent.isTaggedAddressableNotes(followLists.addresses)
|
||||
}
|
||||
}
|
||||
|
||||
fun isATagInList(aTag: ATag): Boolean {
|
||||
if (followLists == null) return false
|
||||
|
||||
return aTag.pubKeyHex in followLists.users
|
||||
return aTag.pubKeyHex in followLists.authors
|
||||
}
|
||||
|
||||
fun match(
|
||||
@@ -89,12 +98,13 @@ class FilterByListParams(
|
||||
fun create(
|
||||
userHex: String,
|
||||
selectedListName: String,
|
||||
followLists: Account.LiveFollowLists?,
|
||||
followLists: Account.LiveFollowList?,
|
||||
hiddenUsers: Account.LiveHiddenUsers,
|
||||
): FilterByListParams =
|
||||
FilterByListParams(
|
||||
isGlobal = selectedListName == GLOBAL_FOLLOWS,
|
||||
isHiddenList = showHiddenKey(selectedListName, userHex),
|
||||
isAroundMe = selectedListName == AROUND_ME,
|
||||
followLists = followLists,
|
||||
hiddenLists = hiddenUsers,
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ class GeoHashFeedFilter(
|
||||
it.event is AudioHeaderEvent
|
||||
) &&
|
||||
it.event?.isTaggedGeoHash(geoTag) == true &&
|
||||
!it.isHiddenFor(account.flowHiddenUsers.value) &&
|
||||
account.isAcceptable(it)
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
|
||||
|
||||
@@ -68,6 +68,7 @@ class HashtagFeedFilter(
|
||||
it.event is AudioHeaderEvent
|
||||
) &&
|
||||
it.event?.isTaggedHash(hashTag) == true &&
|
||||
!it.isHiddenFor(account.flowHiddenUsers.value) &&
|
||||
account.isAcceptable(it)
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
@@ -75,6 +76,7 @@ class HomeConversationsFeedFilter(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is CommentEvent ||
|
||||
it.event is LiveActivitiesChatMessageEvent
|
||||
) &&
|
||||
filterParams.match(it.event) &&
|
||||
|
||||
@@ -26,8 +26,10 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
@@ -98,6 +100,8 @@ class HomeNewThreadFeedFilter(
|
||||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
|
||||
noteEvent is PollNoteEvent ||
|
||||
noteEvent is HighlightEvent ||
|
||||
noteEvent is InteractiveStoryPrologueEvent ||
|
||||
noteEvent is CommentEvent ||
|
||||
noteEvent is AudioTrackEvent ||
|
||||
noteEvent is AudioHeaderEvent
|
||||
) &&
|
||||
|
||||
@@ -115,7 +115,7 @@ class NotificationFeedFilter(
|
||||
it.event !is NIP90ContentDiscoveryRequestEvent &&
|
||||
it.event !is GiftWrapEvent &&
|
||||
(it.event is LnZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) &&
|
||||
(filterParams.isGlobal || filterParams.followLists?.authors?.contains(notifAuthor) == true) &&
|
||||
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
|
||||
tagsAnEventByUser(it, loggedInUserHex)
|
||||
|
||||
@@ -27,19 +27,28 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.model.ThreadLevelCalculator
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@Immutable
|
||||
class ThreadFeedFilter(
|
||||
val account: Account,
|
||||
val noteId: String,
|
||||
private val noteId: String,
|
||||
) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String = noteId
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf()
|
||||
val followingKeySet = account.liveKind3Follows.value.users
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
|
||||
val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
|
||||
val followingKeySet = account.liveKind3Follows.value.authors
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList()
|
||||
|
||||
// Filter out drafts made by other accounts on device
|
||||
val filteredEvents =
|
||||
eventsToWatch.allNotes
|
||||
.filter { !it.isDraft() || (it.author?.pubkeyHex == account.userProfile().pubkeyHex) }
|
||||
.toImmutableSet()
|
||||
val filteredThreadInfo = ThreadAssembler.ThreadInfo(eventsToWatch.root, filteredEvents)
|
||||
|
||||
val eventsInHex = filteredThreadInfo.allNotes.map { it.idHex }.toSet()
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Currently orders by date of each event, descending, at each level of the reply stack
|
||||
@@ -56,6 +65,6 @@ class ThreadFeedFilter(
|
||||
).signature
|
||||
}
|
||||
|
||||
return eventsToWatch.sortedWith(order)
|
||||
return filteredThreadInfo.allNotes.sortedWith(order)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
@@ -73,6 +74,7 @@ class UserProfileNewThreadFeedFilter(
|
||||
it.event is WikiNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is HighlightEvent ||
|
||||
it.event is InteractiveStoryPrologueEvent ||
|
||||
it.event is AudioTrackEvent ||
|
||||
it.event is AudioHeaderEvent ||
|
||||
it.event is TorrentEvent
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isImageOrVideoUrl
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -28,6 +29,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PictureEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
|
||||
@@ -66,10 +68,11 @@ class VideoFeedFilter(
|
||||
val noteEvent = it.event
|
||||
|
||||
return (
|
||||
(noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
|
||||
(noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
|
||||
(noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
|
||||
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
|
||||
(noteEvent is FileHeaderEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
|
||||
(noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
|
||||
(noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && (noteEvent.urls().any { isImageOrVideoUrl(it) } || noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))) ||
|
||||
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ||
|
||||
noteEvent is PictureEvent
|
||||
) &&
|
||||
params.match(noteEvent) &&
|
||||
(params.isHiddenList || account.isAcceptable(it))
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
@@ -48,6 +49,8 @@ fun FeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
state = listState,
|
||||
|
||||
@@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -52,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomListScreen
|
||||
@@ -270,15 +275,51 @@ fun AppNavigation(
|
||||
composable(
|
||||
Route.NIP47Setup.route,
|
||||
Route.NIP47Setup.arguments,
|
||||
enterTransition = { slideInHorizontallyFromEnd },
|
||||
enterTransition = { slideInVerticallyFromBottom },
|
||||
exitTransition = { scaleOut },
|
||||
popEnterTransition = { scaleIn },
|
||||
popExitTransition = { slideOutHorizontallyToEnd },
|
||||
popExitTransition = { slideOutVerticallyToBottom },
|
||||
) {
|
||||
val nip47 = it.arguments?.getString("nip47")
|
||||
|
||||
NIP47SetupScreen(accountViewModel, nav, nip47)
|
||||
}
|
||||
|
||||
composable(
|
||||
Route.NewPost.route,
|
||||
Route.NewPost.arguments,
|
||||
enterTransition = { slideInVerticallyFromBottom },
|
||||
exitTransition = { scaleOut },
|
||||
popEnterTransition = { scaleIn },
|
||||
popExitTransition = { slideOutVerticallyToBottom },
|
||||
) {
|
||||
val draftMessage = it.arguments?.getString("message")?.ifBlank { null }
|
||||
val attachment =
|
||||
it.arguments?.getString("attachment")?.ifBlank { null }?.let {
|
||||
Uri.parse(it)
|
||||
}
|
||||
val baseReplyTo = it.arguments?.getString("baseReplyTo")
|
||||
val quote = it.arguments?.getString("quote")
|
||||
val fork = it.arguments?.getString("fork")
|
||||
val version = it.arguments?.getString("version")
|
||||
val draft = it.arguments?.getString("draft")
|
||||
val enableMessageInterface = it.arguments?.getBoolean("enableMessageInterface") ?: false
|
||||
val enableGeolocation = it.arguments?.getBoolean("enableGeolocation") ?: false
|
||||
|
||||
NewPostScreen(
|
||||
message = draftMessage,
|
||||
attachment = attachment,
|
||||
baseReplyTo = baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
quote = quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
fork = fork?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
version = version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
draft = draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
|
||||
enableMessageInterface = enableMessageInterface,
|
||||
enableGeolocation = enableGeolocation,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,88 +336,129 @@ private fun NavigateIfIntentRequested(
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
) {
|
||||
val activity = LocalContext.current.getActivity()
|
||||
var newAccount by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var currentIntentNextPage by remember {
|
||||
mutableStateOf(
|
||||
activity.intent
|
||||
?.data
|
||||
?.toString()
|
||||
?.ifBlank { null },
|
||||
)
|
||||
}
|
||||
if (activity.intent.action == Intent.ACTION_SEND) {
|
||||
// avoids restarting the new Post screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions
|
||||
if (isBaseRoute(nav.controller, Route.NewPost.base)) return
|
||||
|
||||
currentIntentNextPage?.let { intentNextPage ->
|
||||
var actionableNextPage by remember {
|
||||
mutableStateOf(uriToRoute(intentNextPage))
|
||||
// saves the intent to avoid processing again
|
||||
var message by remember {
|
||||
mutableStateOf(
|
||||
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
it.ifBlank { null }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(intentNextPage) {
|
||||
if (actionableNextPage != null) {
|
||||
actionableNextPage?.let {
|
||||
val currentRoute = getRouteWithArguments(nav.controller)
|
||||
if (!isSameRoute(currentRoute, it)) {
|
||||
nav.newStack(it)
|
||||
}
|
||||
actionableNextPage = null
|
||||
}
|
||||
} else if (intentNextPage.contains("ncryptsec1")) {
|
||||
// login functions
|
||||
Nip19Bech32.tryParseAndClean(intentNextPage)?.let {
|
||||
newAccount = it
|
||||
}
|
||||
var media by remember {
|
||||
mutableStateOf(
|
||||
(activity.intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri),
|
||||
)
|
||||
}
|
||||
|
||||
actionableNextPage = null
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
R.string.invalid_nip19_uri,
|
||||
R.string.invalid_nip19_uri_description,
|
||||
intentNextPage,
|
||||
)
|
||||
nav.newStack(buildNewPostRoute(draftMessage = message, attachment = media))
|
||||
|
||||
media = null
|
||||
message = null
|
||||
} else {
|
||||
var newAccount by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var currentIntentNextPage by remember {
|
||||
mutableStateOf(
|
||||
activity.intent
|
||||
?.data
|
||||
?.toString()
|
||||
?.ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
currentIntentNextPage?.let { intentNextPage ->
|
||||
var actionableNextPage by remember {
|
||||
mutableStateOf(uriToRoute(intentNextPage))
|
||||
}
|
||||
|
||||
currentIntentNextPage = null
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(nav, activity) {
|
||||
val consumer =
|
||||
Consumer<Intent> { intent ->
|
||||
val uri = intent.data?.toString()
|
||||
if (!uri.isNullOrBlank()) {
|
||||
// navigation functions
|
||||
val newPage = uriToRoute(uri)
|
||||
|
||||
if (newPage != null) {
|
||||
LaunchedEffect(intentNextPage) {
|
||||
if (actionableNextPage != null) {
|
||||
actionableNextPage?.let {
|
||||
val currentRoute = getRouteWithArguments(nav.controller)
|
||||
if (!isSameRoute(currentRoute, newPage)) {
|
||||
nav.newStack(newPage)
|
||||
if (!isSameRoute(currentRoute, it)) {
|
||||
nav.newStack(it)
|
||||
}
|
||||
} else if (uri.contains("ncryptsec")) {
|
||||
// login functions
|
||||
Nip19Bech32.tryParseAndClean(uri)?.let {
|
||||
newAccount = it
|
||||
actionableNextPage = null
|
||||
}
|
||||
} else if (intentNextPage.contains("ncryptsec1")) {
|
||||
// login functions
|
||||
Nip19Bech32.tryParseAndClean(intentNextPage)?.let {
|
||||
newAccount = it
|
||||
}
|
||||
|
||||
actionableNextPage = null
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
R.string.invalid_nip19_uri,
|
||||
R.string.invalid_nip19_uri_description,
|
||||
intentNextPage,
|
||||
)
|
||||
}
|
||||
|
||||
currentIntentNextPage = null
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(nav, activity) {
|
||||
val consumer =
|
||||
Consumer<Intent> { intent ->
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
// avoids restarting the new Post screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions
|
||||
if (!isBaseRoute(nav.controller, Route.NewPost.base)) {
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
nav.newStack(buildNewPostRoute(draftMessage = it))
|
||||
}
|
||||
|
||||
(intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
||||
nav.newStack(buildNewPostRoute(attachment = it))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
accountViewModel.toast(
|
||||
R.string.invalid_nip19_uri,
|
||||
R.string.invalid_nip19_uri_description,
|
||||
uri,
|
||||
)
|
||||
val uri = intent.data?.toString()
|
||||
if (!uri.isNullOrBlank()) {
|
||||
// navigation functions
|
||||
val newPage = uriToRoute(uri)
|
||||
|
||||
if (newPage != null) {
|
||||
val currentRoute = getRouteWithArguments(nav.controller)
|
||||
if (!isSameRoute(currentRoute, newPage)) {
|
||||
nav.newStack(newPage)
|
||||
}
|
||||
} else if (uri.contains("ncryptsec")) {
|
||||
// login functions
|
||||
Nip19Bech32.tryParseAndClean(uri)?.let {
|
||||
newAccount = it
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
accountViewModel.toast(
|
||||
R.string.invalid_nip19_uri,
|
||||
R.string.invalid_nip19_uri_description,
|
||||
uri,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
activity.addOnNewIntentListener(consumer)
|
||||
onDispose { activity.removeOnNewIntentListener(consumer) }
|
||||
}
|
||||
activity.addOnNewIntentListener(consumer)
|
||||
onDispose { activity.removeOnNewIntentListener(consumer) }
|
||||
}
|
||||
|
||||
if (newAccount != null) {
|
||||
AddAccountDialog(newAccount, accountStateViewModel) { newAccount = null }
|
||||
if (newAccount != null) {
|
||||
AddAccountDialog(newAccount, accountStateViewModel) { newAccount = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +486,9 @@ private fun isSameRoute(
|
||||
return false
|
||||
}
|
||||
|
||||
val slideInVerticallyFromBottom = slideInVertically(animationSpec = tween(), initialOffsetY = { it })
|
||||
val slideOutVerticallyToBottom = slideOutVertically(animationSpec = tween(), targetOffsetY = { it })
|
||||
|
||||
val slideInHorizontallyFromEnd = slideInHorizontally(animationSpec = tween(), initialOffsetX = { it })
|
||||
val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), targetOffsetX = { it })
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.CodeName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.FollowListState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -112,7 +112,7 @@ private fun LoggedInUserPictureDrawer(
|
||||
fun FollowListWithRoutes(
|
||||
followListsModel: FollowListState,
|
||||
listName: String,
|
||||
onChange: (CodeName) -> Unit,
|
||||
onChange: (FeedDefinition) -> Unit,
|
||||
) {
|
||||
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -128,7 +128,7 @@ fun FollowListWithRoutes(
|
||||
fun FollowListWithoutRoutes(
|
||||
followListsModel: FollowListState,
|
||||
listName: String,
|
||||
onChange: (CodeName) -> Unit,
|
||||
onChange: (FeedDefinition) -> Unit,
|
||||
) {
|
||||
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -394,7 +394,7 @@ private fun FollowingAndFollowerCounts(
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
followingCount.value.users.size
|
||||
followingCount.value.authors.size
|
||||
.toString(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
+86
-6
@@ -20,10 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -33,6 +35,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
@@ -41,12 +45,21 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.map
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.CodeName
|
||||
import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
|
||||
import com.vitorpamplona.amethyst.ui.screen.HashtagName
|
||||
import com.vitorpamplona.amethyst.ui.screen.Name
|
||||
@@ -55,15 +68,17 @@ import com.vitorpamplona.amethyst.ui.screen.ResourceName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun FeedFilterSpinner(
|
||||
placeholderCode: String,
|
||||
explainer: String,
|
||||
options: ImmutableList<CodeName>,
|
||||
options: ImmutableList<FeedDefinition>,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -75,20 +90,85 @@ fun FeedFilterSpinner(
|
||||
id = R.string.select_an_option,
|
||||
)
|
||||
|
||||
var currentText by
|
||||
var selected by
|
||||
remember(placeholderCode, options) {
|
||||
mutableStateOf(
|
||||
options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption,
|
||||
options.firstOrNull { it.code == placeholderCode },
|
||||
)
|
||||
}
|
||||
|
||||
val currentText by
|
||||
remember(placeholderCode, options) {
|
||||
derivedStateOf {
|
||||
selected?.name?.name(context) ?: selectAnOption
|
||||
}
|
||||
}
|
||||
|
||||
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
|
||||
LaunchedEffect(locationPermissionState.status.isGranted) {
|
||||
Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Size20Modifier)
|
||||
Text(currentText)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(currentText)
|
||||
|
||||
if (selected is AroundMeFeedDefinition) {
|
||||
if (!locationPermissionState.status.isGranted) {
|
||||
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
} else {
|
||||
val location by Amethyst.instance.locationManager.geohashStateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
when (val myLocation = location) {
|
||||
is LocationState.LocationResult.Success -> {
|
||||
LoadCityName(
|
||||
geohashStr = myLocation.geoHash.toString(),
|
||||
onLoading = {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation()
|
||||
},
|
||||
) { cityName ->
|
||||
Text(
|
||||
text = "($cityName)",
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LocationState.LocationResult.LackPermission -> {
|
||||
Text(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
}
|
||||
LocationState.LocationResult.Loading -> {
|
||||
Text(
|
||||
text = stringRes(R.string.loading_location),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = explainer,
|
||||
@@ -115,7 +195,7 @@ fun FeedFilterSpinner(
|
||||
options = options,
|
||||
onDismiss = { optionsShowing = false },
|
||||
onSelect = {
|
||||
currentText = options[it].name.name(context)
|
||||
selected = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Immutable
|
||||
@@ -39,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.net.URLEncoder
|
||||
|
||||
@Immutable
|
||||
sealed class Route(
|
||||
@@ -220,8 +222,35 @@ sealed class Route(
|
||||
},
|
||||
).toImmutableList(),
|
||||
)
|
||||
|
||||
object NewPost :
|
||||
Route(
|
||||
route = "NewPost?message={message}&attachment={attachment}&baseReplyTo={baseReplyTo}"e={quote}&fork={fork}&version={version}&draft={draft}&enableGeolocation={enableGeolocation}&enableMessageInterface={enableMessageInterface}",
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments =
|
||||
listOf(
|
||||
navArgument("message") { type = NavType.StringType },
|
||||
navArgument("attachment") { type = NavType.StringType },
|
||||
navArgument("baseReplyTo") { type = NavType.StringType },
|
||||
navArgument("quote") { type = NavType.StringType },
|
||||
navArgument("fork") { type = NavType.StringType },
|
||||
navArgument("version") { type = NavType.StringType },
|
||||
navArgument("draft") { type = NavType.StringType },
|
||||
navArgument("enableGeolocation") { type = NavType.BoolType },
|
||||
navArgument("enableMessageInterface") { type = NavType.BoolType },
|
||||
).toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
fun isBaseRoute(
|
||||
navController: NavHostController,
|
||||
startsWith: String,
|
||||
): Boolean =
|
||||
navController.currentBackStackEntry
|
||||
?.destination
|
||||
?.route
|
||||
?.startsWith(startsWith) ?: false
|
||||
|
||||
fun getRouteWithArguments(navController: NavHostController): String? {
|
||||
val currentEntry = navController.currentBackStackEntry ?: return null
|
||||
return getRouteWithArguments(currentEntry.destination, currentEntry.arguments)
|
||||
@@ -270,3 +299,25 @@ private fun getRouteWithArguments(
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
fun buildNewPostRoute(
|
||||
draftMessage: String? = null,
|
||||
attachment: Uri? = null,
|
||||
baseReplyTo: String? = null,
|
||||
quote: String? = null,
|
||||
fork: String? = null,
|
||||
version: String? = null,
|
||||
draft: String? = null,
|
||||
enableGeolocation: Boolean = false,
|
||||
enableMessageInterface: Boolean = false,
|
||||
): String =
|
||||
"NewPost?" +
|
||||
"message=${draftMessage?.let { URLEncoder.encode(it, "utf-8") } ?: ""}&" +
|
||||
"attachment=${attachment?.let { URLEncoder.encode(it.toString(), "utf-8") } ?: ""}&" +
|
||||
"baseReplyTo=${baseReplyTo ?: ""}&" +
|
||||
"quote=${quote ?: ""}&" +
|
||||
"fork=${fork ?: ""}&" +
|
||||
"version=${version ?: ""}&" +
|
||||
"draft=${draft ?: ""}&" +
|
||||
"enableGeolocation=$enableGeolocation&" +
|
||||
"enableMessageInterface=$enableMessageInterface"
|
||||
|
||||
@@ -61,7 +61,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -671,13 +671,13 @@ fun LoadModerators(
|
||||
|
||||
val followingKeySet =
|
||||
accountViewModel.account.liveDiscoveryFollowLists.value
|
||||
?.users
|
||||
?.authors
|
||||
val allParticipants =
|
||||
ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
|
||||
|
||||
val newParticipantUsers =
|
||||
if (followingKeySet == null) {
|
||||
val allFollows = accountViewModel.account.liveKind3Follows.value.users
|
||||
val allFollows = accountViewModel.account.liveKind3Follows.value.authors
|
||||
val followingParticipants =
|
||||
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
|
||||
|
||||
@@ -724,7 +724,7 @@ private fun LoadParticipants(
|
||||
|
||||
val followingKeySet =
|
||||
accountViewModel.account.liveDiscoveryFollowLists.value
|
||||
?.users
|
||||
?.authors
|
||||
|
||||
val allParticipants =
|
||||
ParticipantListBuilder()
|
||||
@@ -733,7 +733,7 @@ private fun LoadParticipants(
|
||||
|
||||
val newParticipantUsers =
|
||||
if (followingKeySet == null) {
|
||||
val allFollows = accountViewModel.account.liveKind3Follows.value.users
|
||||
val allFollows = accountViewModel.account.liveKind3Follows.value.authors
|
||||
val followingParticipants =
|
||||
ParticipantListBuilder()
|
||||
.followsThatParticipateOn(baseNote, allFollows)
|
||||
@@ -882,7 +882,7 @@ fun RenderChannelThumb(
|
||||
launch(Dispatchers.IO) {
|
||||
val followingKeySet =
|
||||
accountViewModel.account.liveDiscoveryFollowLists.value
|
||||
?.users
|
||||
?.authors
|
||||
val allParticipants =
|
||||
ParticipantListBuilder()
|
||||
.followsThatParticipateOn(baseNote, followingKeySet)
|
||||
@@ -890,7 +890,7 @@ fun RenderChannelThumb(
|
||||
|
||||
val newParticipantUsers =
|
||||
if (followingKeySet == null) {
|
||||
val allFollows = accountViewModel.account.liveKind3Follows.value.users
|
||||
val allFollows = accountViewModel.account.liveKind3Follows.value.authors
|
||||
val followingParticipants =
|
||||
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList()
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ fun LoadCityName(
|
||||
CachedGeoLocations
|
||||
.geoLocate(geohashStr, geoHash.toLocation(), context)
|
||||
?.ifBlank { null }
|
||||
|
||||
if (newCityName != null && newCityName != cityName) {
|
||||
cityName = newCityName
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.note.types.EmptyState
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
|
||||
@@ -100,6 +101,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent
|
||||
@@ -157,6 +159,7 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
@@ -169,12 +172,14 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.events.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.InteractiveStoryBaseEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PictureEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
@@ -428,7 +433,7 @@ fun ClickableNote(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val updatedModifier =
|
||||
remember(baseNote, backgroundColor.value) {
|
||||
remember(baseNote, backgroundColor.value, modifier) {
|
||||
modifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
@@ -683,6 +688,19 @@ private fun RenderNoteRow(
|
||||
nav,
|
||||
)
|
||||
}
|
||||
is CommentEvent -> {
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
makeItShort,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
unPackReply,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
is NIP90ContentDiscoveryResponseEvent ->
|
||||
RenderNIP90ContentDiscoveryResponse(
|
||||
baseNote,
|
||||
@@ -716,6 +734,8 @@ private fun RenderNoteRow(
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, false, accountViewModel)
|
||||
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
|
||||
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
|
||||
is PictureEvent -> PictureDisplay(baseNote, true, false, backgroundColor, accountViewModel, nav)
|
||||
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, false, accountViewModel)
|
||||
is CommunityPostApprovalEvent -> {
|
||||
RenderPostApproval(
|
||||
@@ -780,6 +800,17 @@ private fun RenderNoteRow(
|
||||
nav,
|
||||
)
|
||||
|
||||
is InteractiveStoryBaseEvent ->
|
||||
RenderInteractiveStory(
|
||||
baseNote,
|
||||
makeItShort,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
else -> {
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
|
||||
@@ -85,10 +85,10 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -201,24 +201,16 @@ fun NoteQuickActionMenu(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val editDraftDialog = remember { mutableStateOf(false) }
|
||||
|
||||
if (editDraftDialog.value) {
|
||||
NewPostView(
|
||||
onClose = {
|
||||
onDismiss()
|
||||
editDraftDialog.value = false
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
draft = note,
|
||||
nav = EmptyNav,
|
||||
)
|
||||
}
|
||||
|
||||
NoteQuickActionMenu(
|
||||
note = note,
|
||||
onDismiss = onDismiss,
|
||||
onWantsToEditDraft = { editDraftDialog.value = true },
|
||||
onWantsToEditDraft = {
|
||||
val route =
|
||||
buildNewPostRoute(
|
||||
draft = note.idHex,
|
||||
)
|
||||
nav.nav(route)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -31,6 +32,7 @@ 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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -42,7 +44,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -63,37 +64,172 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.StringToastMsg
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockVitorAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BigPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PollNotePreview() {
|
||||
val event =
|
||||
PollNoteEvent(
|
||||
id = "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72",
|
||||
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
createdAt = 1729186078,
|
||||
content = "Testing polls again",
|
||||
sig = "540101837a8826e2ae28401ee5f4fd8606def8501bec92a74a9e05264bb2c67558b927edf23bb085f5b5f0d91a61c65a25c37a3b92075bf10c9be03dbbe8e94e",
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("poll_option", "0", "OP1"),
|
||||
arrayOf("poll_option", "1", "OP2"),
|
||||
arrayOf("poll_option", "2", "OP3"),
|
||||
arrayOf("value_maximum", "2"),
|
||||
arrayOf("value_minimum", "2"),
|
||||
arrayOf("alt", "Poll event"),
|
||||
),
|
||||
)
|
||||
|
||||
val zapVote =
|
||||
LnZapEvent(
|
||||
id = "2a17fdcd0e387d1623c7313d7aa2848e18dde8a942cfe8a2d6b686ea5f68f01a",
|
||||
pubKey = "79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432",
|
||||
createdAt = 1729186293,
|
||||
content = "Testing polls again",
|
||||
sig = "819cbd8daccd173bc411f71deb4dc0fd7d281797f7f0318d2fbc6c5076e4ebe0aa52c16fd113ca5fcc3ee133cd0c4a11c6d3f77e4fa957d5401da1aff07028d0",
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("p", "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a"),
|
||||
arrayOf("e", "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72"),
|
||||
arrayOf("P", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"),
|
||||
arrayOf("bolt11", "lnbc20n1pn3zj05dqqnp4qtfc238rdkzsj26waa3l8zgag9damzltzsqcrlscj9gvpc7ch2qs2pp5nz0dn8hg5d8z5z9cgd3x8uhnv68hkgg03v3phqvdm26r0trcm27ssp5qxgfplfnfl9q6lh96stly8t90zgkyy4emlgdhadgp8n60eh8ghzs9qyysgqcqpcxqyz5vqrzjqvdnqyc82a9maxu6c7mee0shqr33u4z9z04wpdwhf96gxzpln8jcrapyqqqqqqp2rcqqqqlgqqqqqzsq2qrzjqw9fu4j39mycmg440ztkraa03u5qhtuc5zfgydsv6ml38qd4azymlapyqqqqqqqp9sqqqqlgqqqq86qqjqrzjq26922n6s5n5undqrf78rjjhgpcczafws45tx8237y7pzx3fg8wwxrgayyqq2mgqqqqqqqqqqqqqqqqq2qzkp9q0nyx5508kumhsa8c5x82c96nuccvszlmann6mzf7qvjagwsmmft3acvvqz5q92uf5et4yr53zcgmvphtg5xephcpe5lqle26kcp530mr3"),
|
||||
arrayOf("preimage", "a2ea9951a3ced7bc9d6099ce28a12f027bb1c2116106a6baf7001122c3e08891"),
|
||||
arrayOf("description", "{\"id\":\"c37b692b2bb23cd543b7643962d336fe3ae96a5330769ea047808b20f80b0c6f\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1729186292,\"kind\":9734,\"tags\":[[\"e\",\"6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72\"],[\"p\",\"f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a\"],[\"relays\",\"wss://nos.lol/\",\"wss://vitor.nostr1.com/\"],[\"alt\",\"Zap request\"],[\"poll_option\",\"0\"]],\"content\":\"\",\"sig\":\"83f2243e27d804bad13443937ddf71de7c049e85f71264eaf3a1b751a04844a65ec4bb5916722a1cdf9248f46dc5e03a92be9de543f03d022fb3e7ea08d1cf00\"}"),
|
||||
),
|
||||
)
|
||||
|
||||
val accountViewModel = mockVitorAccountViewModel()
|
||||
val nav = EmptyNav
|
||||
val baseNote: Note?
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.justConsume(event, null)
|
||||
LocalCache.consume(zapVote.zapRequest!!)
|
||||
LocalCache.consume(zapVote, null)
|
||||
baseNote = LocalCache.getOrCreateNote("6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72")
|
||||
}
|
||||
}
|
||||
|
||||
val color = MaterialTheme.colorScheme.background
|
||||
|
||||
if (baseNote != null) {
|
||||
ThemeComparisonColumn(
|
||||
toPreview = {
|
||||
Column(
|
||||
Modifier.padding(10.dp),
|
||||
) {
|
||||
PollNote(
|
||||
baseNote = baseNote,
|
||||
true,
|
||||
remember { mutableStateOf(color) },
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PollNotePreview2() {
|
||||
val event =
|
||||
PollNoteEvent(
|
||||
id = "3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab",
|
||||
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
createdAt = 1729191389,
|
||||
content = "Test",
|
||||
sig = "fc7f83e4dc84082a9a473338d9df2fdb876c59fbc94d49fdbce1e0bfd85cc7d50b14d4f0d08e8a84ef46f99a4e889e261cc898e2caeca4a153d8925254c5a761",
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("poll_option", "0", "Pesquisa em portugues"),
|
||||
arrayOf("poll_option", "1", "Pesquisa em ingles"),
|
||||
arrayOf("value_maximum", "2"),
|
||||
arrayOf("value_minimum", "2"),
|
||||
arrayOf("alt", "Poll event"),
|
||||
),
|
||||
)
|
||||
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
val nav = EmptyNav
|
||||
val baseNote: Note?
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.justConsume(event, null)
|
||||
baseNote = LocalCache.getOrCreateNote("3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab")
|
||||
}
|
||||
}
|
||||
|
||||
val color = MaterialTheme.colorScheme.background
|
||||
|
||||
if (baseNote != null) {
|
||||
ThemeComparisonColumn(
|
||||
toPreview = {
|
||||
Column(
|
||||
Modifier.padding(10.dp),
|
||||
) {
|
||||
PollNote(
|
||||
baseNote = baseNote,
|
||||
true,
|
||||
remember { mutableStateOf(color) },
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollNote(
|
||||
baseNote: Note,
|
||||
@@ -202,6 +338,7 @@ private fun OptionNote(
|
||||
nonClickablePrepend = {},
|
||||
clickablePrepend = {
|
||||
RenderOptionBeforeVote(
|
||||
baseNote,
|
||||
poolOption.descriptor,
|
||||
canPreview,
|
||||
tags,
|
||||
@@ -238,13 +375,7 @@ private fun RenderOptionAfterVote(
|
||||
QuoteBorder,
|
||||
),
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = color,
|
||||
progress = {
|
||||
poolOption.tally.value.toFloat()
|
||||
},
|
||||
)
|
||||
DisplayProgress(poolOption, color, modifier = Modifier.matchParentSize())
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -258,10 +389,7 @@ private fun RenderOptionAfterVote(
|
||||
.width(45.dp)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = "${(poolOption.tally.value.toFloat() * 100).roundToInt()}%",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
TallyText(poolOption)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -289,8 +417,46 @@ private fun RenderOptionAfterVote(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayProgress(
|
||||
poolOption: PollOption,
|
||||
color: Color,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val progress by poolOption.tally
|
||||
|
||||
// The LinearProgressIndicator has some weird update issues and renders inaccurate percentages.
|
||||
Box(modifier = modifier) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(progress)
|
||||
.fillMaxHeight()
|
||||
.background(color = color),
|
||||
) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TallyText(poolOption: PollOption) {
|
||||
val state = poolOption.tally
|
||||
val progressTxt by
|
||||
remember {
|
||||
derivedStateOf {
|
||||
"${(state.value * 100).roundToInt()}%"
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = progressTxt,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderOptionBeforeVote(
|
||||
baseNote: Note,
|
||||
description: String,
|
||||
canPreview: Boolean,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
@@ -308,17 +474,19 @@ private fun RenderOptionBeforeVote(
|
||||
QuoteBorder,
|
||||
),
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = description,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = 1,
|
||||
modifier = BigPadding,
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = description,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Column(BigPadding) {
|
||||
TranslatableRichTextViewer(
|
||||
content = description,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier,
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = baseNote.idHex + description,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,16 +550,12 @@ fun ZapVote(
|
||||
)
|
||||
return@combinedClickable
|
||||
} else if (
|
||||
accountViewModel.account.settings.zapAmountChoices.value.size == 1 &&
|
||||
pollViewModel.isValidInputVoteAmount(
|
||||
accountViewModel.account.settings.zapAmountChoices.value
|
||||
.first(),
|
||||
)
|
||||
accountViewModel.zapAmountChoices().size == 1 &&
|
||||
pollViewModel.isValidInputVoteAmount(accountViewModel.zapAmountChoices().first())
|
||||
) {
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
accountViewModel.account.settings.zapAmountChoices.value
|
||||
.first() * 1000,
|
||||
accountViewModel.zapAmountChoices().first() * 1000,
|
||||
poolOption.option,
|
||||
"",
|
||||
context,
|
||||
@@ -524,7 +688,7 @@ fun FilteredZapAmountChoicePopup(
|
||||
val context = LocalContext.current
|
||||
|
||||
// TODO: Move this to the viewModel
|
||||
val zapPaymentChoices by accountViewModel.account.settings.zapAmountChoices
|
||||
val zapPaymentChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val zapMessage = ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user