kopia lustrzana https://github.com/vitorpamplona/amethyst
Merge remote-tracking branch 'origin/HEAD' into nip-65-relay-change
# Conflicts: # app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.ktnip-65-relay-change
commit
f3b09c07c3
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.22" />
|
||||
<option name="version" value="1.9.23" />
|
||||
</component>
|
||||
</project>
|
|
@ -12,9 +12,9 @@ android {
|
|||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 362
|
||||
versionName "0.85.3"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"d8da33fd13d129d86c53564aedefafbe3716f007c520431be4a8e488d3925afb\""
|
||||
versionCode 368
|
||||
versionName "0.86.5"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"a704a11334ed4fe6fc6ee6f8856f6f005da33644770616f1437f8b2b488b52b1\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
@ -143,7 +143,7 @@ android {
|
|||
|
||||
composeOptions {
|
||||
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
|
||||
kotlinCompilerExtensionVersion "1.5.8"
|
||||
kotlinCompilerExtensionVersion "1.5.11"
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
|
@ -151,7 +151,6 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
@ -162,13 +161,13 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform(libs.androidx.compose.bom)
|
||||
|
||||
implementation project(path: ':quartz')
|
||||
implementation project(path: ':commons')
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.activity.compose
|
||||
|
||||
implementation platform(libs.androidx.compose.bom)
|
||||
|
||||
implementation libs.androidx.ui
|
||||
implementation libs.androidx.ui.graphics
|
||||
implementation libs.androidx.ui.tooling.preview
|
||||
|
@ -205,9 +204,6 @@ dependencies {
|
|||
// Websockets API
|
||||
implementation libs.okhttp
|
||||
|
||||
// HTML Parsing for Link Preview
|
||||
implementation libs.jsoup
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation libs.androidx.security.crypto.ktx
|
||||
|
||||
|
@ -282,9 +278,13 @@ dependencies {
|
|||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.mockk
|
||||
|
||||
androidTestImplementation platform(libs.androidx.compose.bom)
|
||||
androidTestImplementation libs.androidx.junit
|
||||
androidTestImplementation libs.androidx.junit.ktx
|
||||
androidTestImplementation libs.androidx.espresso.core
|
||||
|
||||
debugImplementation platform(libs.androidx.compose.bom)
|
||||
debugImplementation libs.androidx.ui.tooling
|
||||
debugImplementation libs.androidx.ui.test.manifest
|
||||
}
|
||||
|
|
|
@ -20,6 +20,8 @@
|
|||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
|
@ -29,70 +31,17 @@ import com.vitorpamplona.amethyst.service.Nip96Uploader
|
|||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ImageUploadTesting {
|
||||
val contentType = "image/gif"
|
||||
val image =
|
||||
"R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzW" +
|
||||
"lwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2c" +
|
||||
"cMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjA" +
|
||||
"J8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8A" +
|
||||
"AF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMu" +
|
||||
"QeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSH" +
|
||||
"pzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGR" +
|
||||
"s/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78A" +
|
||||
"AAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMi" +
|
||||
"wocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7G" +
|
||||
"nwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euT" +
|
||||
"eJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dt" +
|
||||
"GCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWl" +
|
||||
"Mc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPe" +
|
||||
"iUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYI" +
|
||||
"m4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZ" +
|
||||
"cNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9" +
|
||||
"aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3A" +
|
||||
"DTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kV" +
|
||||
"MyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDG" +
|
||||
"qCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMW" +
|
||||
"ZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bD" +
|
||||
"GdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB77" +
|
||||
"6aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJH" +
|
||||
"gxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiA" +
|
||||
"FB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPA" +
|
||||
"gCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHg" +
|
||||
"rhGSQJxCS+0pCZbEhAAOw=="
|
||||
|
||||
val contentTypePng = "image/png"
|
||||
val imagePng =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3" +
|
||||
"/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXd" +
|
||||
"tdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEn" +
|
||||
"xBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nH" +
|
||||
"L0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2ud" +
|
||||
"LFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8" +
|
||||
"Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoeP" +
|
||||
"PQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/" +
|
||||
"9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlw" +
|
||||
"jlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN97" +
|
||||
"9jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC1" +
|
||||
"7MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2r" +
|
||||
"eNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+h" +
|
||||
"uNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66Pfyu" +
|
||||
"Rj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMT" +
|
||||
"hZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
|
||||
|
||||
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
|
||||
val serverInfo =
|
||||
Nip96Retriever()
|
||||
|
@ -100,7 +49,15 @@ class ImageUploadTesting {
|
|||
server.baseUrl,
|
||||
)
|
||||
|
||||
val bytes = Base64.getDecoder().decode(imagePng)
|
||||
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(KeyPair())
|
||||
|
@ -110,7 +67,7 @@ class ImageUploadTesting {
|
|||
.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
contentTypePng,
|
||||
"image/png",
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverInfo,
|
||||
|
@ -124,31 +81,31 @@ class ImageUploadTesting {
|
|||
val contentType = result.tags!!.first { it[0] == "m" }.get(1)
|
||||
val ox = result.tags!!.first { it[0] == "ox" }.get(1)
|
||||
|
||||
Assert.assertTrue(url.startsWith("http"))
|
||||
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
|
||||
|
||||
val imageData: ByteArray =
|
||||
ImageDownloader().waitAndGetImage(url)
|
||||
?: run {
|
||||
fail("Should not be null")
|
||||
fail("${server.name}: Should not be null")
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
contentTypePng,
|
||||
"image/png",
|
||||
null,
|
||||
onReady = {
|
||||
if (dim != null) {
|
||||
assertEquals(dim, it.dim)
|
||||
// assertEquals("${server.name}: Invalid dimensions", it.dim, dim)
|
||||
}
|
||||
if (size != null) {
|
||||
assertEquals(size, it.size.toString())
|
||||
// assertEquals("${server.name}: Invalid size", it.size.toString(), size)
|
||||
}
|
||||
if (hash != null) {
|
||||
assertEquals(hash, it.hash)
|
||||
assertEquals("${server.name}: Invalid hash", it.hash, hash)
|
||||
}
|
||||
},
|
||||
onError = { fail("It should not fail") },
|
||||
onError = { fail("${server.name}: It should not fail") },
|
||||
)
|
||||
|
||||
// delay(1000)
|
||||
|
@ -156,6 +113,14 @@ class ImageUploadTesting {
|
|||
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runTestOnDefaultServers() =
|
||||
runBlocking {
|
||||
Nip96MediaServers.DEFAULT.forEach {
|
||||
testBase(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrCheck() =
|
||||
runBlocking {
|
||||
|
@ -163,12 +128,14 @@ class ImageUploadTesting {
|
|||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrage() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSove() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
|
||||
|
@ -181,6 +148,7 @@ class ImageUploadTesting {
|
|||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSovbit() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
|
||||
|
@ -191,4 +159,17 @@ class ImageUploadTesting {
|
|||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrPic() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrOnch() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services"))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,9 +47,11 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.commonmark.MarkdownParseOptions
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material3.Material3RichText
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
|
||||
|
@ -101,12 +103,18 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
|
|||
onDismissRequest = { distributorPresent = true },
|
||||
title = { Text(stringResource(R.string.push_server_install_app)) },
|
||||
text = {
|
||||
Material3RichText(
|
||||
val content = stringResource(R.string.push_server_install_app_description)
|
||||
|
||||
val astNode =
|
||||
remember {
|
||||
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content)
|
||||
}
|
||||
|
||||
RichText(
|
||||
style = RichTextStyle().resolveDefaults(),
|
||||
renderer = null,
|
||||
) {
|
||||
Markdown(
|
||||
content = stringResource(R.string.push_server_install_app_description),
|
||||
)
|
||||
BasicMarkdown(astNode)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
|
|
@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
|||
fun TranslatableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
|
@ -40,6 +41,7 @@ fun TranslatableRichTextViewer(
|
|||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
|
|
|
@ -27,18 +27,12 @@ import android.util.Log
|
|||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.BooleanType
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
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.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.model.parseBooleanType
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.parseThemeType
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
|
@ -354,15 +348,6 @@ object LocalPreferences {
|
|||
return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
}
|
||||
|
||||
suspend fun migrateOldSharedSettings(): Settings? {
|
||||
val prefs = encryptedPreferences()
|
||||
loadOldSharedSettings(prefs)?.let {
|
||||
saveSharedSettings(it, prefs)
|
||||
return it
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun saveSharedSettings(
|
||||
sharedSettings: Settings,
|
||||
prefs: SharedPreferences = encryptedPreferences(),
|
||||
|
@ -390,67 +375,6 @@ object LocalPreferences {
|
|||
}
|
||||
}
|
||||
|
||||
@Deprecated("Turned into a single JSON object")
|
||||
suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
with(prefs) {
|
||||
if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val automaticallyShowImages =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
val automaticallyStartPlayback =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyShowUrlPreview =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyHideNavigationBars =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
|
||||
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
|
||||
} else {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePictures =
|
||||
if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
val themeType =
|
||||
if (contains(PrefKeys.THEME)) {
|
||||
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
|
||||
} else {
|
||||
ThemeType.SYSTEM
|
||||
}
|
||||
|
||||
return Settings(
|
||||
themeType,
|
||||
getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null },
|
||||
automaticallyShowImages,
|
||||
automaticallyStartPlayback,
|
||||
automaticallyShowUrlPreview,
|
||||
automaticallyHideNavigationBars,
|
||||
automaticallyShowProfilePictures,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val mutex = Mutex()
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? =
|
||||
|
|
|
@ -56,10 +56,12 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
|||
import com.vitorpamplona.quartz.events.Contact
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiUrl
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
|
@ -106,11 +108,15 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flattenMerge
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -207,178 +213,121 @@ class Account(
|
|||
val saveable: AccountLiveData = AccountLiveData(this)
|
||||
|
||||
@Immutable
|
||||
data class LiveFollowLists(
|
||||
class LiveFollowLists(
|
||||
val users: ImmutableSet<String> = persistentSetOf(),
|
||||
val hashtags: ImmutableSet<String> = persistentSetOf(),
|
||||
val geotags: ImmutableSet<String> = persistentSetOf(),
|
||||
val communities: ImmutableSet<String> = persistentSetOf(),
|
||||
)
|
||||
|
||||
class ListNameNotePair(val listName: String, val event: GeneralListEvent?)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveKind3Follows: StateFlow<LiveFollowLists> by lazy {
|
||||
userProfile()
|
||||
.live()
|
||||
.follows
|
||||
.asFlow()
|
||||
.transformLatest {
|
||||
emit(
|
||||
LiveFollowLists(
|
||||
userProfile().cachedFollowingKeySet().toImmutableSet(),
|
||||
userProfile().cachedFollowingTagSet().toImmutableSet(),
|
||||
userProfile().cachedFollowingGeohashSet().toImmutableSet(),
|
||||
userProfile().cachedFollowingCommunitiesSet().toImmutableSet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
val liveKind3FollowsFlow: Flow<LiveFollowLists> =
|
||||
userProfile().flow().follows.stateFlow.transformLatest {
|
||||
emit(
|
||||
LiveFollowLists(
|
||||
it.user.cachedFollowingKeySet().toImmutableSet(),
|
||||
it.user.cachedFollowingTagSet().toImmutableSet(),
|
||||
it.user.cachedFollowingGeohashSet().toImmutableSet(),
|
||||
it.user.cachedFollowingCommunitiesSet().toImmutableSet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val liveKind3Follows = liveKind3FollowsFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val liveHomeList: Flow<ListNameNotePair> by lazy {
|
||||
defaultHomeFollowList.flatMapLatest { listName ->
|
||||
loadPeopleListFlowFromListName(listName)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val liveHomeList: StateFlow<NoteState?> by lazy {
|
||||
defaultHomeFollowList
|
||||
.transformLatest {
|
||||
LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let {
|
||||
emit(it)
|
||||
fun loadPeopleListFlowFromListName(listName: String): Flow<ListNameNotePair> {
|
||||
return if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
|
||||
note?.flow()?.metadata?.stateFlow?.mapLatest {
|
||||
val noteEvent = it.note.event as? GeneralListEvent
|
||||
ListNameNotePair(listName, noteEvent)
|
||||
} ?: MutableStateFlow(ListNameNotePair(listName, null))
|
||||
} else {
|
||||
MutableStateFlow(ListNameNotePair(listName, null))
|
||||
}
|
||||
}
|
||||
|
||||
fun combinePeopleListFlows(
|
||||
kind3FollowsSource: Flow<LiveFollowLists>,
|
||||
peopleListFollowsSource: Flow<ListNameNotePair>,
|
||||
): Flow<LiveFollowLists?> {
|
||||
return combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows ->
|
||||
if (peopleListFollows.listName == GLOBAL_FOLLOWS) {
|
||||
emit(null)
|
||||
} else if (peopleListFollows.listName == KIND3_FOLLOWS) {
|
||||
emit(kind3Follows)
|
||||
} else if (peopleListFollows.event == null) {
|
||||
emit(LiveFollowLists())
|
||||
} else {
|
||||
val result = waitToDecrypt(peopleListFollows.event)
|
||||
if (result == null) {
|
||||
emit(LiveFollowLists())
|
||||
} else {
|
||||
emit(result)
|
||||
}
|
||||
}
|
||||
.flattenMerge()
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
}
|
||||
}
|
||||
|
||||
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy {
|
||||
combineTransform(defaultHomeFollowList, liveKind3Follows, liveHomeList) {
|
||||
listName,
|
||||
kind3Follows,
|
||||
peopleListFollows,
|
||||
->
|
||||
if (listName == GLOBAL_FOLLOWS) {
|
||||
emit(null)
|
||||
} else if (listName == KIND3_FOLLOWS) {
|
||||
emit(kind3Follows)
|
||||
} else {
|
||||
val result =
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
decryptLiveFollows(peopleListFollows) { continuation.resume(it) }
|
||||
}
|
||||
}
|
||||
result?.let { emit(it) } ?: run { emit(LiveFollowLists()) }
|
||||
}
|
||||
}
|
||||
combinePeopleListFlows(liveKind3FollowsFlow, liveHomeList)
|
||||
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val liveNotificationList: StateFlow<NoteState?> by lazy {
|
||||
private val liveNotificationList: Flow<ListNameNotePair> by lazy {
|
||||
defaultNotificationFollowList
|
||||
.transformLatest {
|
||||
LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let {
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
.flattenMerge()
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
.transformLatest { listName ->
|
||||
emit(loadPeopleListFlowFromListName(listName))
|
||||
}.flattenMerge()
|
||||
}
|
||||
|
||||
val liveNotificationFollowLists: StateFlow<LiveFollowLists?> by lazy {
|
||||
combineTransform(defaultNotificationFollowList, liveKind3Follows, liveNotificationList) {
|
||||
listName,
|
||||
kind3Follows,
|
||||
peopleListFollows,
|
||||
->
|
||||
if (listName == GLOBAL_FOLLOWS) {
|
||||
emit(null)
|
||||
} else if (listName == KIND3_FOLLOWS) {
|
||||
emit(kind3Follows)
|
||||
} else {
|
||||
val result =
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
decryptLiveFollows(peopleListFollows) { continuation.resume(it) }
|
||||
}
|
||||
}
|
||||
result?.let { emit(it) } ?: run { emit(LiveFollowLists()) }
|
||||
}
|
||||
}
|
||||
combinePeopleListFlows(liveKind3FollowsFlow, liveNotificationList)
|
||||
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val liveStoriesList: StateFlow<NoteState?> by lazy {
|
||||
private val liveStoriesList: Flow<ListNameNotePair> by lazy {
|
||||
defaultStoriesFollowList
|
||||
.transformLatest {
|
||||
LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let {
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
.flattenMerge()
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
.transformLatest { listName ->
|
||||
emit(loadPeopleListFlowFromListName(listName))
|
||||
}.flattenMerge()
|
||||
}
|
||||
|
||||
val liveStoriesFollowLists: StateFlow<LiveFollowLists?> by lazy {
|
||||
combineTransform(defaultStoriesFollowList, liveKind3Follows, liveStoriesList) {
|
||||
listName,
|
||||
kind3Follows,
|
||||
peopleListFollows,
|
||||
->
|
||||
if (listName == GLOBAL_FOLLOWS) {
|
||||
emit(null)
|
||||
} else if (listName == KIND3_FOLLOWS) {
|
||||
emit(kind3Follows)
|
||||
} else {
|
||||
val result =
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
decryptLiveFollows(peopleListFollows) { continuation.resume(it) }
|
||||
}
|
||||
}
|
||||
result?.let { emit(it) } ?: run { emit(LiveFollowLists()) }
|
||||
}
|
||||
}
|
||||
combinePeopleListFlows(liveKind3FollowsFlow, liveStoriesList)
|
||||
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val liveDiscoveryList: StateFlow<NoteState?> by lazy {
|
||||
private val liveDiscoveryList: Flow<ListNameNotePair> by lazy {
|
||||
defaultDiscoveryFollowList
|
||||
.transformLatest {
|
||||
LocalCache.checkGetOrCreateAddressableNote(it)?.flow()?.metadata?.stateFlow?.let {
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
.flattenMerge()
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
.transformLatest { listName ->
|
||||
emit(loadPeopleListFlowFromListName(listName))
|
||||
}.flattenMerge()
|
||||
}
|
||||
|
||||
val liveDiscoveryFollowLists: StateFlow<LiveFollowLists?> by lazy {
|
||||
combineTransform(defaultDiscoveryFollowList, liveKind3Follows, liveDiscoveryList) {
|
||||
listName,
|
||||
kind3Follows,
|
||||
peopleListFollows,
|
||||
->
|
||||
if (listName == GLOBAL_FOLLOWS) {
|
||||
emit(null)
|
||||
} else if (listName == KIND3_FOLLOWS) {
|
||||
emit(kind3Follows)
|
||||
} else {
|
||||
val result =
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
decryptLiveFollows(peopleListFollows) { continuation.resume(it) }
|
||||
}
|
||||
}
|
||||
result?.let { emit(it) } ?: run { emit(LiveFollowLists()) }
|
||||
}
|
||||
}
|
||||
combinePeopleListFlows(liveKind3FollowsFlow, liveDiscoveryList)
|
||||
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
}
|
||||
|
||||
private fun decryptLiveFollows(
|
||||
peopleListFollows: NoteState?,
|
||||
listEvent: GeneralListEvent,
|
||||
onReady: (LiveFollowLists) -> Unit,
|
||||
) {
|
||||
val listEvent = (peopleListFollows?.note?.event as? GeneralListEvent)
|
||||
listEvent?.privateTags(signer) { privateTagList ->
|
||||
listEvent.privateTags(signer) { privateTagList ->
|
||||
onReady(
|
||||
LiveFollowLists(
|
||||
users =
|
||||
|
@ -396,6 +345,16 @@ class Account(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowLists? {
|
||||
return withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
decryptLiveFollows(peopleListFollows) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LiveHiddenUsers(
|
||||
val hiddenUsers: ImmutableSet<String>,
|
||||
|
@ -572,7 +531,7 @@ class Account(
|
|||
if (!isWriteable()) return
|
||||
|
||||
MetadataEvent.updateFromPast(
|
||||
latest = userProfile().info?.latestMetadata,
|
||||
latest = userProfile().latestMetadata,
|
||||
name = name,
|
||||
picture = picture,
|
||||
banner = banner,
|
||||
|
@ -750,6 +709,7 @@ class Account(
|
|||
fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onSent: () -> Unit,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
@ -771,6 +731,8 @@ class Account(
|
|||
LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } }
|
||||
|
||||
Client.send(event, nip47.relayUri, wcListener.feedTypes) { wcListener.destroy() }
|
||||
|
||||
onSent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -836,17 +798,18 @@ class Account(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun delete(note: Note) {
|
||||
return delete(listOf(note))
|
||||
fun delete(note: Note) {
|
||||
delete(listOf(note))
|
||||
}
|
||||
|
||||
suspend fun delete(notes: List<Note>) {
|
||||
fun delete(notes: List<Note>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val myNotes = notes.filter { it.author == userProfile() }.mapNotNull { it.event?.id() }
|
||||
val myEvents = notes.filter { it.author == userProfile() }
|
||||
val myNoteVersions = myEvents.mapNotNull { it.event as? Event }
|
||||
|
||||
if (myNotes.isNotEmpty()) {
|
||||
DeletionEvent.create(myNotes, signer) {
|
||||
if (myNoteVersions.isNotEmpty()) {
|
||||
DeletionEvent.create(myNoteVersions, signer) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
|
@ -921,6 +884,7 @@ class Account(
|
|||
|
||||
fun timestamp(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
if (note.isDraft()) return
|
||||
|
||||
val id = note.event?.id() ?: note.idHex
|
||||
|
||||
|
@ -1310,6 +1274,7 @@ class Account(
|
|||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
|
@ -1337,14 +1302,26 @@ class Account(
|
|||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
if (draftTag != null) {
|
||||
if (message.isBlank()) {
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1365,6 +1342,7 @@ class Account(
|
|||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
|
@ -1388,26 +1366,52 @@ class Account(
|
|||
nip94attachments = nip94attachments,
|
||||
forkedFrom = forkedFrom,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
// broadcast replied notes
|
||||
replyingTo?.let {
|
||||
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
if (draftTag != null) {
|
||||
if (message.isBlank()) {
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
} else {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
// broadcast replied notes
|
||||
replyingTo?.let {
|
||||
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(
|
||||
fun deleteDraft(draftTag: String) {
|
||||
val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag)
|
||||
LocalCache.getAddressableNoteIfExists(key)?.let {
|
||||
val noteEvent = it.event
|
||||
if (noteEvent is DraftEvent) {
|
||||
noteEvent.createDeletedEvent(signer) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
delete(it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendPost(
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
mentions: List<User>?,
|
||||
|
@ -1422,6 +1426,7 @@ class Account(
|
|||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
|
@ -1445,20 +1450,32 @@ class Account(
|
|||
nip94attachments = nip94attachments,
|
||||
forkedFrom = forkedFrom,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
// broadcast replied notes
|
||||
replyingTo?.let {
|
||||
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
if (draftTag != null) {
|
||||
if (message.isBlank()) {
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
} else {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
// broadcast replied notes
|
||||
replyingTo?.let {
|
||||
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1502,6 +1519,7 @@ class Account(
|
|||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
|
@ -1525,15 +1543,27 @@ class Account(
|
|||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
if (draftTag != null) {
|
||||
if (message.isBlank()) {
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
// Rebroadcast replies and tags to the current relay set
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
// Rebroadcast replies and tags to the current relay set
|
||||
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1549,6 +1579,7 @@ class Account(
|
|||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
|
@ -1566,9 +1597,21 @@ class Account(
|
|||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
if (draftTag != null) {
|
||||
if (message.isBlank()) {
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent)
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1582,6 +1625,7 @@ class Account(
|
|||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
|
@ -1600,9 +1644,21 @@ class Account(
|
|||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
if (draftTag != null) {
|
||||
if (message.isBlank()) {
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent)
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1616,6 +1672,7 @@ class Account(
|
|||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
sendPrivateMessage(
|
||||
message,
|
||||
|
@ -1627,6 +1684,7 @@ class Account(
|
|||
zapRaiserAmount,
|
||||
geohash,
|
||||
nip94attachments,
|
||||
draftTag,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1640,6 +1698,7 @@ class Account(
|
|||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
|
@ -1659,9 +1718,21 @@ class Account(
|
|||
nip94attachments = nip94attachments,
|
||||
signer = signer,
|
||||
advertiseNip18 = false,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it, null)
|
||||
if (draftTag != null) {
|
||||
if (message.isBlank()) {
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
|
||||
Client.send(draftEvent)
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1676,6 +1747,7 @@ class Account(
|
|||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String? = null,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
|
@ -1693,9 +1765,21 @@ class Account(
|
|||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
draftTag = draftTag,
|
||||
signer = signer,
|
||||
) {
|
||||
broadcastPrivately(it)
|
||||
if (draftTag != null) {
|
||||
if (message.isBlank()) {
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent ->
|
||||
Client.send(draftEvent)
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
broadcastPrivately(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1777,7 +1861,7 @@ class Account(
|
|||
Client.send(event)
|
||||
LocalCache.justConsume(event, null)
|
||||
|
||||
DeletionEvent.create(listOf(event.id), signer) { event2 ->
|
||||
DeletionEvent.createForVersionOnly(listOf(event), signer) { event2 ->
|
||||
Client.send(event2)
|
||||
LocalCache.justConsume(event2, null)
|
||||
}
|
||||
|
@ -1843,6 +1927,7 @@ class Account(
|
|||
isPrivate: Boolean,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
if (note.isDraft()) return
|
||||
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.addReplaceable(
|
||||
|
@ -2209,13 +2294,18 @@ class Account(
|
|||
}
|
||||
|
||||
fun cachedDecryptContent(note: Note): String? {
|
||||
val event = note.event
|
||||
return cachedDecryptContent(note.event)
|
||||
}
|
||||
|
||||
fun cachedDecryptContent(event: EventInterface?): String? {
|
||||
if (event == null) return null
|
||||
|
||||
return if (event is PrivateDmEvent && isWriteable()) {
|
||||
event.cachedContentFor(signer)
|
||||
} else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) {
|
||||
event.cachedPrivateZap()?.content
|
||||
} else {
|
||||
event?.content()
|
||||
event.content()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.model
|
|||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.commons.data.LargeCache
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
|
@ -33,7 +35,6 @@ import com.vitorpamplona.quartz.encoders.toNote
|
|||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Stable
|
||||
class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
|
@ -107,10 +108,9 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
|
|||
@Stable
|
||||
abstract class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
val notes = LargeCache<HexKey, Note>()
|
||||
var lastNoteCreatedAt: Long = 0
|
||||
|
||||
open fun id() = Hex.decode(idHex)
|
||||
|
||||
|
@ -131,7 +131,7 @@ abstract class Channel(val idHex: String) {
|
|||
}
|
||||
|
||||
open fun profilePicture(): String? {
|
||||
return creator?.profilePicture()
|
||||
return creator?.info?.banner
|
||||
}
|
||||
|
||||
open fun updateChannelInfo(
|
||||
|
@ -145,7 +145,11 @@ abstract class Channel(val idHex: String) {
|
|||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
notes[note.idHex] = note
|
||||
notes.put(note.idHex, note)
|
||||
|
||||
if ((note.createdAt() ?: 0) > lastNoteCreatedAt) {
|
||||
lastNoteCreatedAt = note.createdAt() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
|
@ -163,18 +167,18 @@ abstract class Channel(val idHex: String) {
|
|||
|
||||
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
|
||||
val important =
|
||||
notes.values
|
||||
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
.take(1000)
|
||||
notes.filter { key, it ->
|
||||
it.author?.let { author -> account.isHidden(author) } == false
|
||||
}
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
.take(500)
|
||||
.toSet()
|
||||
|
||||
val toBeRemoved = notes.values.filter { it !in important }.toSet()
|
||||
val toBeRemoved = notes.filter { key, it -> it !in important }
|
||||
|
||||
toBeRemoved.forEach { notes.remove(it.idHex) }
|
||||
|
||||
return toBeRemoved
|
||||
return toBeRemoved.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||
|
||||
@Stable
|
||||
class Chatroom() {
|
||||
var authors: Set<User> = setOf()
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
var subject: String? = null
|
||||
var subjectCreatedAt: Long? = null
|
||||
|
@ -38,6 +39,12 @@ class Chatroom() {
|
|||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
msg.author?.let { author ->
|
||||
if (author !in authors) {
|
||||
authors += author
|
||||
}
|
||||
}
|
||||
|
||||
val newSubject = msg.event?.subject()
|
||||
|
||||
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
|
||||
|
@ -51,8 +58,8 @@ class Chatroom() {
|
|||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
if (msg in roomMessages) {
|
||||
roomMessages = roomMessages - msg
|
||||
|
||||
roomMessages
|
||||
.filter { it.event?.subject() != null }
|
||||
|
@ -66,7 +73,7 @@ class Chatroom() {
|
|||
}
|
||||
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean {
|
||||
return roomMessages.any { it.author?.pubkeyHex in keySet }
|
||||
return authors.any { it.pubkeyHex in keySet }
|
||||
}
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
|
|
|
@ -21,162 +21,91 @@
|
|||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Btc
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Mate
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Nostr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Plebs
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Skull
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Weed
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Zap
|
||||
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.ui.components.HashTag
|
||||
import com.vitorpamplona.amethyst.ui.components.RenderRegular
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
|
||||
fun checkForHashtagWithIcon(
|
||||
tag: String,
|
||||
primary: Color,
|
||||
): HashtagIcon? {
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderHashTagIcons() {
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
ThemeComparisonColumn {
|
||||
RenderRegular(
|
||||
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate",
|
||||
EmptyTagList,
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"₿itcoin",
|
||||
"bitcoin",
|
||||
"btc",
|
||||
"timechain",
|
||||
"bitcoiner",
|
||||
"bitcoiners",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.ht_btc,
|
||||
"Bitcoin",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"nostr",
|
||||
"nostrich",
|
||||
"nostriches",
|
||||
"thenostr",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.ht_nostr,
|
||||
"Nostr",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"lightning",
|
||||
"lightningnetwork",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.ht_lightning,
|
||||
"Lightning",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"zap",
|
||||
"zaps",
|
||||
"zapper",
|
||||
"zappers",
|
||||
"zapping",
|
||||
"zapped",
|
||||
"zapathon",
|
||||
"zapraiser",
|
||||
"zaplife",
|
||||
"zapchain",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.zap,
|
||||
"Zap",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"amethyst" ->
|
||||
HashtagIcon(
|
||||
R.drawable.amethyst,
|
||||
"Amethyst",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"onyx" ->
|
||||
HashtagIcon(
|
||||
R.drawable.black_heart,
|
||||
"Onyx",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"cashu",
|
||||
"ecash",
|
||||
"nut",
|
||||
"nuts",
|
||||
"deeznuts",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.cashu,
|
||||
"Cashu",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"plebs",
|
||||
"pleb",
|
||||
"plebchain",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.plebs,
|
||||
"Pleb",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp),
|
||||
)
|
||||
"coffee",
|
||||
"coffeechain",
|
||||
"cafe",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.coffee,
|
||||
"Coffee",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"skullofsatoshi" ->
|
||||
HashtagIcon(
|
||||
R.drawable.skull,
|
||||
"SkullofSatoshi",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"grownostr",
|
||||
"gardening",
|
||||
"garden",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.grownostr,
|
||||
"GrowNostr",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp),
|
||||
)
|
||||
"footstr" ->
|
||||
HashtagIcon(
|
||||
R.drawable.footstr,
|
||||
"Footstr",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"tunestr",
|
||||
"music",
|
||||
"nowplaying",
|
||||
->
|
||||
HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
|
||||
"weed",
|
||||
"weedstr",
|
||||
"420",
|
||||
"cannabis",
|
||||
"marijuana",
|
||||
->
|
||||
HashtagIcon(
|
||||
R.drawable.weed,
|
||||
"Weed",
|
||||
Color.Unspecified,
|
||||
Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
)
|
||||
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
|
||||
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
|
||||
"lightning", "lightningnetwork" -> lightning
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> zap
|
||||
"amethyst" -> amethyst
|
||||
"cashu", "ecash", "nut", "nuts", "deeznuts" -> cashu
|
||||
"plebs", "pleb", "plebchain" -> plebs
|
||||
"coffee", "coffeechain", "cafe" -> coffee
|
||||
"skullofsatoshi" -> skull
|
||||
"grownostr", "gardening", "garden" -> growstr
|
||||
"footstr" -> footstr
|
||||
"tunestr", "music", "nowplaying" -> tunestr
|
||||
"mate", "matechain", "matestr" -> matestr
|
||||
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val lightning = HashtagIcon(CustomHashTagIcons.Lightning, "Lightning", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val zap = HashtagIcon(CustomHashTagIcons.Zap, "Zap", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val amethyst = HashtagIcon(CustomHashTagIcons.Amethyst, "Amethyst", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
|
||||
val cashu = HashtagIcon(CustomHashTagIcons.Cashu, "Cashu", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val plebs = HashtagIcon(CustomHashTagIcons.Plebs, "Pleb", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
|
||||
val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(start = 3.dp, bottom = 1.dp, top = 1.dp))
|
||||
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
|
||||
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
|
||||
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
|
||||
|
||||
@Immutable
|
||||
class HashtagIcon(
|
||||
val icon: Int,
|
||||
val icon: ImageVector,
|
||||
val description: String,
|
||||
val color: Color,
|
||||
val modifier: Modifier,
|
||||
val modifier: Modifier = Modifier,
|
||||
)
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
|||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
|
@ -97,6 +98,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
|||
fun dTag(): String? {
|
||||
return (event as? AddressableEvent)?.dTag()
|
||||
}
|
||||
|
||||
override fun wasOrShouldBeDeletedBy(
|
||||
deletionEvents: Set<HexKey>,
|
||||
deletionAddressables: Set<ATag>,
|
||||
): Boolean {
|
||||
val thisEvent = event
|
||||
return deletionAddressables.contains(address) || (thisEvent != null && deletionEvents.contains(thisEvent.id()))
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
|
@ -171,7 +180,8 @@ open class Note(val idHex: String) {
|
|||
event is LiveActivitiesEvent
|
||||
) {
|
||||
(event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
|
||||
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
|
||||
} else {
|
||||
|
@ -183,6 +193,8 @@ open class Note(val idHex: String) {
|
|||
|
||||
open fun createdAt() = event?.createdAt()
|
||||
|
||||
fun isDraft() = event is DraftEvent
|
||||
|
||||
fun loadEvent(
|
||||
event: Event,
|
||||
author: User,
|
||||
|
@ -310,6 +322,12 @@ open class Note(val idHex: String) {
|
|||
}
|
||||
|
||||
fun removeAllChildNotes(): List<Note> {
|
||||
val repliesChanged = replies.isNotEmpty()
|
||||
val reactionsChanged = reactions.isNotEmpty()
|
||||
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty()
|
||||
val boostsChanged = boosts.isNotEmpty()
|
||||
val reportsChanged = reports.isNotEmpty()
|
||||
|
||||
val toBeRemoved =
|
||||
replies +
|
||||
reactions.values.flatten() +
|
||||
|
@ -330,11 +348,11 @@ open class Note(val idHex: String) {
|
|||
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
liveSet?.innerBoosts?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
if (repliesChanged) liveSet?.innerReplies?.invalidateData()
|
||||
if (reactionsChanged) liveSet?.innerReactions?.invalidateData()
|
||||
if (boostsChanged) liveSet?.innerBoosts?.invalidateData()
|
||||
if (reportsChanged) liveSet?.innerReports?.invalidateData()
|
||||
if (zapsChanged) liveSet?.innerZaps?.invalidateData()
|
||||
|
||||
return toBeRemoved
|
||||
}
|
||||
|
@ -529,7 +547,7 @@ open class Note(val idHex: String) {
|
|||
option: Int?,
|
||||
user: User,
|
||||
account: Account,
|
||||
remainingZapEvents: List<Pair<Note, Note?>>,
|
||||
remainingZapEvents: Map<Note, Note?>,
|
||||
onWasZappedByAuthor: () -> Unit,
|
||||
) {
|
||||
if (remainingZapEvents.isEmpty()) {
|
||||
|
@ -537,8 +555,8 @@ open class Note(val idHex: String) {
|
|||
}
|
||||
|
||||
remainingZapEvents.forEach { next ->
|
||||
val zapRequest = next.first.event as LnZapRequestEvent
|
||||
val zapEvent = next.second?.event as? LnZapEvent
|
||||
val zapRequest = next.key.event as LnZapRequestEvent
|
||||
val zapEvent = next.value?.event as? LnZapEvent
|
||||
|
||||
if (!zapRequest.isPrivateZap()) {
|
||||
// public events
|
||||
|
@ -582,7 +600,7 @@ open class Note(val idHex: String) {
|
|||
account: Account,
|
||||
onWasZappedByAuthor: () -> Unit,
|
||||
) {
|
||||
isZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
|
||||
isZappedByCalculation(null, user, account, zaps, onWasZappedByAuthor)
|
||||
if (account.userProfile() == user) {
|
||||
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
|
||||
}
|
||||
|
@ -594,7 +612,7 @@ open class Note(val idHex: String) {
|
|||
account: Account,
|
||||
onWasZappedByAuthor: () -> Unit,
|
||||
) {
|
||||
isZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
|
||||
isZappedByCalculation(option, user, account, zaps, onWasZappedByAuthor)
|
||||
}
|
||||
|
||||
fun getReactionBy(user: User): String? {
|
||||
|
@ -921,6 +939,14 @@ open class Note(val idHex: String) {
|
|||
createOrDestroyFlowSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
open fun wasOrShouldBeDeletedBy(
|
||||
deletionEvents: Set<HexKey>,
|
||||
deletionAddressables: Set<ATag>,
|
||||
): Boolean {
|
||||
val thisEvent = event
|
||||
return deletionEvents.contains(idHex) || (thisEvent is AddressableEvent && deletionAddressables.contains(thisEvent.address()))
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
|
@ -958,8 +984,6 @@ class NoteLiveSet(u: Note) {
|
|||
val relays = innerRelays.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
|
||||
val authorChanges = innerMetadata.map { it.note.author }.distinctUntilChanged()
|
||||
|
||||
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
|
||||
|
||||
val hasReactions =
|
||||
|
@ -997,7 +1021,6 @@ class NoteLiveSet(u: Note) {
|
|||
reports.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
authorChanges.hasObservers() ||
|
||||
hasEvent.hasObservers() ||
|
||||
hasReactions.hasObservers() ||
|
||||
replyCount.hasObservers() ||
|
||||
|
|
|
@ -96,7 +96,7 @@ class ParticipantListBuilder {
|
|||
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
|
||||
}
|
||||
|
||||
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
|
||||
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ data class Settings(
|
|||
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val dontShowPushNotificationSelector: Boolean = false,
|
||||
val dontAskForNotificationPermissions: Boolean = false,
|
||||
val featureSet: FeatureSetType = FeatureSetType.COMPLETE,
|
||||
)
|
||||
|
||||
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
|
||||
|
@ -59,6 +60,11 @@ enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val res
|
|||
NEVER(false, 2, R.string.connectivity_type_never),
|
||||
}
|
||||
|
||||
enum class FeatureSetType(val screenCode: Int, val resourceId: Int) {
|
||||
COMPLETE(0, R.string.ui_feature_set_type_complete),
|
||||
SIMPLIFIED(1, R.string.ui_feature_set_type_simplified),
|
||||
}
|
||||
|
||||
fun parseConnectivityType(code: Boolean?): ConnectivityType {
|
||||
return when (code) {
|
||||
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
|
||||
|
@ -81,6 +87,16 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
|
|||
}
|
||||
}
|
||||
|
||||
fun parseFeatureSetType(screenCode: Int): FeatureSetType {
|
||||
return when (screenCode) {
|
||||
FeatureSetType.COMPLETE.screenCode -> FeatureSetType.COMPLETE
|
||||
FeatureSetType.SIMPLIFIED.screenCode -> FeatureSetType.SIMPLIFIED
|
||||
else -> {
|
||||
FeatureSetType.COMPLETE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
|
||||
ALWAYS(null, 0, R.string.connectivity_type_always),
|
||||
NEVER(false, 1, R.string.connectivity_type_never),
|
||||
|
|
|
@ -21,6 +21,8 @@
|
|||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
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
|
||||
|
@ -78,7 +80,7 @@ class ThreadAssembler {
|
|||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = mutableSetOf<Note>()
|
||||
val thread = OnlyLatestVersionSet()
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
|
@ -87,7 +89,7 @@ class ThreadAssembler {
|
|||
// did not added them.
|
||||
note.replies.forEach { loadDown(it, thread) }
|
||||
|
||||
thread.toSet()
|
||||
thread
|
||||
} else {
|
||||
setOf(note)
|
||||
}
|
||||
|
@ -109,3 +111,87 @@ class ThreadAssembler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OnlyLatestVersionSet : MutableSet<Note> {
|
||||
val map = hashMapOf<ATag, Long>()
|
||||
val set = hashSetOf<Note>()
|
||||
|
||||
override fun add(element: Note): Boolean {
|
||||
val loadedCreatedAt = element.createdAt()
|
||||
val noteEvent = element.event
|
||||
|
||||
return if (element is AddressableNote && loadedCreatedAt != null) {
|
||||
innerAdd(element.address, element, loadedCreatedAt)
|
||||
} else if (noteEvent is AddressableEvent && loadedCreatedAt != null) {
|
||||
innerAdd(noteEvent.address(), element, loadedCreatedAt)
|
||||
} else {
|
||||
set.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
private fun innerAdd(
|
||||
address: ATag,
|
||||
element: Note,
|
||||
loadedCreatedAt: Long,
|
||||
): Boolean {
|
||||
val existing = map.get(address)
|
||||
return if (existing == null) {
|
||||
map.put(address, loadedCreatedAt)
|
||||
set.add(element)
|
||||
} else {
|
||||
if (loadedCreatedAt > existing) {
|
||||
map.put(address, loadedCreatedAt)
|
||||
set.add(element)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun addAll(elements: Collection<Note>): Boolean {
|
||||
return elements.map { add(it) }.any()
|
||||
}
|
||||
|
||||
override val size: Int
|
||||
get() = set.size
|
||||
|
||||
override fun clear() {
|
||||
set.clear()
|
||||
map.clear()
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean {
|
||||
return set.isEmpty()
|
||||
}
|
||||
|
||||
override fun containsAll(elements: Collection<Note>): Boolean {
|
||||
return set.containsAll(elements)
|
||||
}
|
||||
|
||||
override fun contains(element: Note): Boolean {
|
||||
return set.contains(element)
|
||||
}
|
||||
|
||||
override fun iterator(): MutableIterator<Note> {
|
||||
return set.iterator()
|
||||
}
|
||||
|
||||
override fun retainAll(elements: Collection<Note>): Boolean {
|
||||
return set.retainAll(elements)
|
||||
}
|
||||
|
||||
override fun removeAll(elements: Collection<Note>): Boolean {
|
||||
return elements.map { remove(it) }.any()
|
||||
}
|
||||
|
||||
override fun remove(element: Note): Boolean {
|
||||
element.address()?.let {
|
||||
map.remove(it)
|
||||
}
|
||||
(element.event as? AddressableEvent)?.address()?.let {
|
||||
map.remove(it)
|
||||
}
|
||||
|
||||
return set.remove(element)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,8 +26,6 @@ import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
|||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Stable
|
||||
object UrlCachedPreviewer {
|
||||
|
@ -37,46 +35,44 @@ object UrlCachedPreviewer {
|
|||
suspend fun previewInfo(
|
||||
url: String,
|
||||
onReady: suspend (UrlPreviewState) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
|
||||
BahaUrlPreview(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override suspend fun onComplete(urlInfo: UrlInfoItem) =
|
||||
withContext(Dispatchers.IO) {
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
|
||||
val state =
|
||||
if (urlInfo.fetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
|
||||
override suspend fun onFailed(throwable: Throwable) =
|
||||
withContext(Dispatchers.IO) {
|
||||
cache[url]?.let {
|
||||
override suspend fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val state =
|
||||
if (urlInfo.fetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
|
||||
override suspend fun onFailed(throwable: Throwable) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
},
|
||||
)
|
||||
.fetchUrlPreview()
|
||||
|
|
|
@ -52,6 +52,7 @@ import java.math.BigDecimal
|
|||
class User(val pubkeyHex: String) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
|
||||
|
@ -80,7 +81,7 @@ class User(val pubkeyHex: String) {
|
|||
override fun toString(): String = pubkeyHex
|
||||
|
||||
fun toBestShortFirstName(): String {
|
||||
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
|
||||
val fullName = toBestDisplayName()
|
||||
|
||||
val names = fullName.split(' ')
|
||||
|
||||
|
@ -96,23 +97,14 @@ class User(val pubkeyHex: String) {
|
|||
}
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
|
||||
}
|
||||
|
||||
fun bestUsername(): String? {
|
||||
return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun bestDisplayName(): String? {
|
||||
return info?.displayName?.ifBlank { null }
|
||||
return info?.bestName() ?: pubkeyDisplayHex()
|
||||
}
|
||||
|
||||
fun nip05(): String? {
|
||||
return info?.nip05?.ifBlank { null }
|
||||
return info?.nip05
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (info?.picture.isNullOrBlank()) info?.picture = null
|
||||
return info?.picture
|
||||
}
|
||||
|
||||
|
@ -135,6 +127,7 @@ class User(val pubkeyHex: String) {
|
|||
|
||||
// Update following of the current user
|
||||
liveSet?.innerFollows?.invalidateData()
|
||||
flowSet?.follows?.invalidateData()
|
||||
|
||||
// Update Followers of the past user list
|
||||
// Update Followers of the new contact list
|
||||
|
@ -285,6 +278,18 @@ class User(val pubkeyHex: String) {
|
|||
}
|
||||
}
|
||||
|
||||
fun removeMessage(
|
||||
room: ChatroomKey,
|
||||
msg: Note,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
val privateChatroom = getOrCreatePrivateChatroom(room)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelayBeingUsed(
|
||||
relay: Relay,
|
||||
eventTime: Long,
|
||||
|
@ -307,8 +312,6 @@ class User(val pubkeyHex: String) {
|
|||
latestMetadata: MetadataEvent,
|
||||
) {
|
||||
info = newUserInfo
|
||||
info?.latestMetadata = latestMetadata
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
info?.tags = latestMetadata.tags.toImmutableListOfLists()
|
||||
info?.cleanBlankNames()
|
||||
|
||||
|
@ -364,7 +367,7 @@ class User(val pubkeyHex: String) {
|
|||
}
|
||||
|
||||
suspend fun transientFollowerCount(): Int {
|
||||
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun cachedFollowingKeySet(): Set<HexKey> {
|
||||
|
@ -388,13 +391,13 @@ class User(val pubkeyHex: String) {
|
|||
}
|
||||
|
||||
suspend fun cachedFollowerCount(): Int {
|
||||
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
|
||||
val messagesToUser = privateChatrooms[key] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
|
||||
return messagesToUser.authors.any { this == it }
|
||||
}
|
||||
|
||||
fun hasReport(
|
||||
|
@ -472,14 +475,16 @@ class User(val pubkeyHex: String) {
|
|||
@Stable
|
||||
class UserFlowSet(u: User) {
|
||||
// Observers line up here.
|
||||
val follows = UserBundledRefresherFlow(u)
|
||||
val relays = UserBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return relays.stateFlow.subscriptionCount.value > 0
|
||||
return relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
relays.destroy()
|
||||
follows.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
object CachedRichTextParser {
|
||||
|
|
|
@ -39,6 +39,7 @@ object HttpClientManager {
|
|||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
private var defaultHttpClient: OkHttpClient? = null
|
||||
private var defaultHttpClientWithoutProxy: OkHttpClient? = null
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by
|
||||
|
@ -58,6 +59,10 @@ object HttpClientManager {
|
|||
}
|
||||
}
|
||||
|
||||
fun getDefaultProxy(): Proxy? {
|
||||
return this.internalProxy
|
||||
}
|
||||
|
||||
fun setDefaultTimeout(timeout: Duration) {
|
||||
Log.d("HttpClient", "Changing timeout to: $timeout")
|
||||
if (this.defaultTimeout.seconds != timeout.seconds) {
|
||||
|
@ -72,7 +77,7 @@ object HttpClientManager {
|
|||
proxy: Proxy?,
|
||||
timeout: Duration,
|
||||
): OkHttpClient {
|
||||
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
|
||||
val seconds = if (proxy != null) timeout.seconds * 3 else timeout.seconds
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
|
@ -98,11 +103,18 @@ object HttpClientManager {
|
|||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
fun getHttpClient(useProxy: Boolean = true): OkHttpClient {
|
||||
return if (useProxy) {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
}
|
||||
defaultHttpClient!!
|
||||
} else {
|
||||
if (this.defaultHttpClientWithoutProxy == null) {
|
||||
this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
|
||||
}
|
||||
defaultHttpClientWithoutProxy!!
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun initProxy(
|
||||
|
|
|
@ -121,8 +121,9 @@ class Nip11Retriever {
|
|||
try {
|
||||
val request: Request =
|
||||
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
|
||||
val isLocalHost = dirtyUrl.startsWith("ws://127.0.0.1") || dirtyUrl.startsWith("ws://localhost")
|
||||
|
||||
HttpClientManager.getHttpClient()
|
||||
HttpClientManager.getHttpClient(useProxy = !isLocalHost)
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
|
|
|
@ -32,8 +32,7 @@ object Nip96MediaServers {
|
|||
listOf(
|
||||
ServerName("Nostr.Build", "https://nostr.build"),
|
||||
ServerName("NostrCheck.me", "https://nostrcheck.me"),
|
||||
ServerName("Nostrage", "https://nostrage.com"),
|
||||
ServerName("Sove", "https://sove.rent"),
|
||||
ServerName("NostPic", "https://nostpic.com"),
|
||||
ServerName("Sovbit", "https://files.sovbit.host"),
|
||||
ServerName("Void.cat", "https://void.cat"),
|
||||
)
|
||||
|
|
|
@ -200,8 +200,6 @@ class Nip96Uploader(val account: Account?) {
|
|||
|
||||
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
|
||||
|
||||
val request =
|
||||
requestBuilder
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
|
|
|
@ -40,6 +40,8 @@ import com.vitorpamplona.quartz.events.CalendarRSVPEvent
|
|||
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DirectMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
|
@ -100,7 +102,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
|||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND),
|
||||
kinds = listOf(StatusEvent.KIND, AdvertisedRelayListEvent.KIND, DirectMessageRelayListEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 5,
|
||||
),
|
||||
|
@ -118,6 +120,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
|||
MetadataEvent.KIND,
|
||||
ContactListEvent.KIND,
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
DirectMessageRelayListEvent.KIND,
|
||||
MuteListEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
),
|
||||
|
@ -144,7 +147,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
|||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(ReportEvent.KIND),
|
||||
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
|
@ -262,22 +265,80 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
|||
checkNotInMainThread()
|
||||
|
||||
if (LocalCache.justVerify(event)) {
|
||||
if (event is GiftWrapEvent) {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note != null && relay.brief in note.relays) return
|
||||
when (event) {
|
||||
is DraftEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
|
||||
event.cachedGift(account.signer) { this.consume(it, relay) }
|
||||
}
|
||||
if (!event.isDeleted()) {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent != null) {
|
||||
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
} else {
|
||||
// decrypts
|
||||
event.cachedDraft(account.signer) {}
|
||||
|
||||
if (event is SealedGossipEvent) {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note != null && relay.brief in note.relays) return
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
|
||||
} else {
|
||||
LocalCache.justConsume(event, relay)
|
||||
is GiftWrapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? GiftWrapEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
noteEvent.cachedGift(account.signer) {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.cachedGift(account.signer) { this.consume(it, relay) }
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is SealedGossipEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? SealedGossipEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
// adds the relay to seal and inner chat
|
||||
LocalCache.consume(noteEvent, relay)
|
||||
noteEvent.cachedGossip(account.signer) {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note?.event == null) {
|
||||
event.zapRequest?.let {
|
||||
if (it.isPrivateZap()) {
|
||||
it.decryptPrivateZap(account.signer) {}
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
|
|||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.Subscription
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
@ -42,9 +43,9 @@ abstract class NostrDataSource(val debugName: String) {
|
|||
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
|
||||
data class Counter(var counter: Int)
|
||||
data class Counter(val subscriptionId: String, val eventKind: Int, var counter: Int)
|
||||
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
private var eventCounter = mapOf<Int, Counter>()
|
||||
var changingFilters = AtomicBoolean()
|
||||
|
||||
private var active: Boolean = false
|
||||
|
@ -53,11 +54,18 @@ abstract class NostrDataSource(val debugName: String) {
|
|||
eventCounter.forEach {
|
||||
Log.d(
|
||||
"STATE DUMP ${this.javaClass.simpleName}",
|
||||
"Received Events ${it.key}: ${it.value.counter}",
|
||||
"Received Events $debugName ${it.value.subscriptionId} ${it.value.eventKind}: ${it.value.counter}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun hashCodeFields(
|
||||
str1: String,
|
||||
str2: Int,
|
||||
): Int {
|
||||
return 31 * str1.hashCode() + str2.hashCode()
|
||||
}
|
||||
|
||||
private val clientListener =
|
||||
object : Client.Listener() {
|
||||
override fun onEvent(
|
||||
|
@ -67,12 +75,12 @@ abstract class NostrDataSource(val debugName: String) {
|
|||
afterEOSE: Boolean,
|
||||
) {
|
||||
if (subscriptions.containsKey(subscriptionId)) {
|
||||
val key = "$debugName $subscriptionId ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
val key = hashCodeFields(subscriptionId, event.kind)
|
||||
val keyValue = eventCounter[key]
|
||||
if (keyValue != null) {
|
||||
keyValue.counter++
|
||||
} else {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
eventCounter = eventCounter + Pair(key, Counter(subscriptionId, event.kind, 1))
|
||||
}
|
||||
|
||||
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}")
|
||||
|
@ -293,7 +301,13 @@ abstract class NostrDataSource(val debugName: String) {
|
|||
eventId: String,
|
||||
relay: Relay,
|
||||
) {
|
||||
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
|
||||
val note = LocalCache.getNoteIfExists(eventId)
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
LocalCache.getAddressableNoteIfExists(noteEvent.address().toTag())?.addRelay(relay)
|
||||
} else {
|
||||
note?.addRelay(relay)
|
||||
}
|
||||
}
|
||||
|
||||
open fun markAsEOSE(
|
||||
|
|
|
@ -178,9 +178,8 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
|||
filter =
|
||||
JsonFilter(
|
||||
authors = follows,
|
||||
kinds =
|
||||
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
|
||||
limit = 300,
|
||||
kinds = listOf(ChannelMessageEvent.KIND),
|
||||
limit = 500,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
|
@ -194,7 +193,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
|||
filter =
|
||||
JsonFilter(
|
||||
ids = followChats,
|
||||
kinds = listOf(ChannelCreateEvent.KIND),
|
||||
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
|
|
|
@ -77,7 +77,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
|||
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
|
|
|
@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service
|
|||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
@ -63,7 +63,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
|||
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.KIND),
|
||||
|
@ -86,7 +86,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
|||
return directEventsToLoad.map {
|
||||
it.address().let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
|
|
|
@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.service
|
|||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
|
@ -57,29 +58,45 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||
}
|
||||
|
||||
return groupByEOSEPresence(addressesToWatch).map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
CommunityPostApprovalEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
),
|
||||
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 1000,
|
||||
),
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
CommunityPostApprovalEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
),
|
||||
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 1000,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
DeletionEvent.KIND,
|
||||
),
|
||||
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 10,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
private fun createAddressFilter(): List<TypedFilter>? {
|
||||
|
@ -93,7 +110,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||
it.address()?.let { aTag ->
|
||||
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
|
@ -103,7 +120,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||
)
|
||||
} else {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
|
@ -125,7 +142,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||
return groupByEOSEPresence(eventsToWatch).map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
|
@ -147,6 +164,20 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||
limit = 1000,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
DeletionEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 10,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
|
@ -159,9 +190,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||
return groupByEOSEPresence(eventsToWatch).map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.KIND),
|
||||
tags = mapOf("q" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
|
@ -190,7 +222,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
ids = interestedEvents.toList(),
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
|
@ -35,13 +35,13 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
|||
fun createUserMetadataFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
|
||||
val firstTimers = usersToWatch.filter { it.latestMetadata == null }.map { it.pubkeyHex }
|
||||
|
||||
if (firstTimers.isEmpty()) return null
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
|
@ -54,7 +54,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
|||
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null }
|
||||
val secondTimers = usersToWatch.filter { it.latestMetadata != null }
|
||||
|
||||
if (secondTimers.isEmpty()) return null
|
||||
|
||||
|
@ -64,7 +64,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
|||
val minEOSEs = findMinimumEOSEsForUsers(group)
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
|
||||
|
@ -73,7 +73,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
|||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(ReportEvent.KIND),
|
||||
|
@ -91,7 +91,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
|||
checkNotInMainThread()
|
||||
|
||||
usersToWatch.forEach {
|
||||
if (it.info?.latestMetadata != null) {
|
||||
if (it.latestMetadata != null) {
|
||||
val eose = it.latestEOSEs[relayUrl]
|
||||
if (eose == null) {
|
||||
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
|
||||
|
|
|
@ -40,6 +40,8 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
|||
|
||||
var job: Job? = null
|
||||
|
||||
val SUPPORTED_VIDEO_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job =
|
||||
|
@ -68,6 +70,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
|||
authors = follows,
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
|
||||
limit = 200,
|
||||
tags = mapOf("m" to SUPPORTED_VIDEO_MIME_TYPES),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
|
@ -93,6 +96,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
|||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
"m" to SUPPORTED_VIDEO_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
|
@ -120,6 +124,7 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
|||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
"m" to SUPPORTED_VIDEO_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
|
|
|
@ -24,7 +24,11 @@ import android.util.Log
|
|||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Immutable data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
|
||||
|
@ -49,21 +53,44 @@ object OnlineChecker {
|
|||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
|
||||
Log.d("OnlineChecker", "isOnline $url")
|
||||
|
||||
return try {
|
||||
val request =
|
||||
Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val result =
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
if (url.startsWith("wss")) {
|
||||
val request =
|
||||
Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url.replace("wss+livekit://", "wss://"))
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Sec-WebSocket-Key", CryptoUtils.random(16).toByteString().base64())
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Extensions", "permessage-deflate")
|
||||
.build()
|
||||
|
||||
val client =
|
||||
HttpClientManager.getHttpClient().newBuilder()
|
||||
.eventListener(EventListener.NONE)
|
||||
.protocols(listOf(Protocol.HTTP_1_1))
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
} else {
|
||||
val request =
|
||||
Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
}
|
||||
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
|||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
|
||||
|
@ -35,7 +36,6 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
|
|||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.round
|
||||
|
||||
|
@ -59,9 +59,8 @@ class ZapPaymentHandler(val account: Account) {
|
|||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val zapSplitSetup = note.event?.zapSplitSetup()
|
||||
|
||||
val noteEvent = note.event
|
||||
val zapSplitSetup = noteEvent?.zapSplitSetup()
|
||||
|
||||
val zapsToSend =
|
||||
if (!zapSplitSetup.isNullOrEmpty()) {
|
||||
|
@ -69,7 +68,7 @@ class ZapPaymentHandler(val account: Account) {
|
|||
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
|
||||
noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) }
|
||||
} else {
|
||||
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(
|
||||
|
@ -84,101 +83,216 @@ class ZapPaymentHandler(val account: Account) {
|
|||
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
|
||||
}
|
||||
|
||||
val totalWeight = zapsToSend.sumOf { it.weight }
|
||||
|
||||
val invoicesToPayOnIntent = mutableListOf<Payable>()
|
||||
|
||||
zapsToSend.forEachIndexed { index, value ->
|
||||
val outerProgressMin = index / zapsToSend.size.toFloat()
|
||||
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
|
||||
|
||||
val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
|
||||
|
||||
if (value.isLnAddress) {
|
||||
innerZap(
|
||||
lud16 = value.lnAddressOrPubKeyHex,
|
||||
note = note,
|
||||
amount = zapValue,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
|
||||
},
|
||||
zapType = zapType,
|
||||
onPayInvoiceThroughIntent = {
|
||||
invoicesToPayOnIntent.add(
|
||||
Payable(
|
||||
info = value,
|
||||
user = null,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = it,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
onProgress(0.02f)
|
||||
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
|
||||
if (splitZapRequestPairs.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@signAllZapRequests
|
||||
} else {
|
||||
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
onProgress(0.05f)
|
||||
}
|
||||
|
||||
if (lud16 != null) {
|
||||
innerZap(
|
||||
lud16 = lud16,
|
||||
note = note,
|
||||
amount = zapValue,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
|
||||
},
|
||||
zapType = zapType,
|
||||
overrideUser = user,
|
||||
onPayInvoiceThroughIntent = {
|
||||
invoicesToPayOnIntent.add(
|
||||
Payable(
|
||||
info = value,
|
||||
user = user,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = it,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, onError, onProgress = {
|
||||
onProgress(it * 0.7f + 0.05f) // keeps within range.
|
||||
}, context) {
|
||||
if (it.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@assembleAllInvoices
|
||||
} else {
|
||||
onError(
|
||||
context.getString(
|
||||
R.string.missing_lud16,
|
||||
),
|
||||
context.getString(
|
||||
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex,
|
||||
),
|
||||
onProgress(0.75f)
|
||||
}
|
||||
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
payViaNWC(it.values.map { it.second }, note, onError, onProgress = {
|
||||
onProgress(it * 0.25f + 0.75f) // keeps within range.
|
||||
}, context) {
|
||||
// onProgress(1f)
|
||||
}
|
||||
} else {
|
||||
onPayViaIntent(
|
||||
it.map {
|
||||
Payable(
|
||||
info = it.key.first,
|
||||
user = null,
|
||||
amountMilliSats = it.value.first,
|
||||
invoice = it.value.second,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
|
||||
onProgress(0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (invoicesToPayOnIntent.isNotEmpty()) {
|
||||
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
|
||||
onProgress(1f)
|
||||
} else {
|
||||
launch(Dispatchers.IO) {
|
||||
// Awaits for the event to come back to LocalCache.
|
||||
var count = 0
|
||||
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
|
||||
count++
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
if (invoicesToPayOnIntent.isNotEmpty()) {
|
||||
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
|
||||
onProgress(1f)
|
||||
private fun calculateZapValue(
|
||||
amountMilliSats: Long,
|
||||
weight: Double,
|
||||
totalWeight: Double,
|
||||
): Long {
|
||||
val shareValue = amountMilliSats * (weight / totalWeight)
|
||||
val roundedZapValue = round(shareValue / 1000f).toLong() * 1000
|
||||
return roundedZapValue
|
||||
}
|
||||
|
||||
suspend fun signAllZapRequests(
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapsToSend: List<ZapSplitSetup>,
|
||||
onAllDone: suspend (MutableMap<ZapSplitSetup, String>) -> Unit,
|
||||
) {
|
||||
collectSuccessfulSigningOperations<ZapSplitSetup, String>(
|
||||
operationsInput = zapsToSend,
|
||||
runRequestFor = { next: ZapSplitSetup, onReady ->
|
||||
if (next.isLnAddress) {
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
|
||||
if (zapRequestJson != null) {
|
||||
onReady(zapRequestJson)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onProgress(1f)
|
||||
val user = LocalCache.getUserIfExists(next.lnAddressOrPubKeyHex)
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user) { zapRequestJson ->
|
||||
if (zapRequestJson != null) {
|
||||
onReady(zapRequestJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun assembleAllInvoices(
|
||||
invoices: List<Pair<ZapSplitSetup, String>>,
|
||||
totalAmountMilliSats: Long,
|
||||
message: String,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onAllDone: suspend (MutableMap<Pair<ZapSplitSetup, String>, Pair<Long, String>>) -> Unit,
|
||||
) {
|
||||
var progressAllPayments = 0.00f
|
||||
val totalWeight = invoices.sumOf { it.first.weight }
|
||||
|
||||
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, String>, Pair<Long, String>>(
|
||||
operationsInput = invoices,
|
||||
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, String>, onReady ->
|
||||
assembleInvoice(
|
||||
splitSetup = splitZapRequestPair.first,
|
||||
nostrZapRequest = splitZapRequestPair.second,
|
||||
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
|
||||
message = message,
|
||||
onError = onError,
|
||||
onProgressStep = { percentStepForThisPayment ->
|
||||
progressAllPayments += percentStepForThisPayment / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
},
|
||||
context = context,
|
||||
onReady = onReady,
|
||||
)
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun payViaNWC(
|
||||
invoices: List<String>,
|
||||
note: Note,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onAllDone: suspend (MutableMap<String, Boolean>) -> Unit,
|
||||
) {
|
||||
var progressAllPayments = 0.00f
|
||||
|
||||
collectSuccessfulSigningOperations<String, Boolean>(
|
||||
operationsInput = invoices,
|
||||
runRequestFor = { invoice: String, onReady ->
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = invoice,
|
||||
zappedNote = note,
|
||||
onSent = {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
onReady(true)
|
||||
},
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
onError(
|
||||
context.getString(R.string.error_dialog_pay_invoice_error),
|
||||
context.getString(
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString() ?: "Error parsing error message",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
private fun assembleInvoice(
|
||||
splitSetup: ZapSplitSetup,
|
||||
nostrZapRequest: String,
|
||||
zapValue: Long,
|
||||
message: String,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgressStep: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onReady: (Pair<Long, String>) -> Unit,
|
||||
) {
|
||||
var progressThisPayment = 0.00f
|
||||
|
||||
var user: User? = null
|
||||
val lud16 =
|
||||
if (splitSetup.isLnAddress) {
|
||||
splitSetup.lnAddressOrPubKeyHex
|
||||
} else {
|
||||
user = LocalCache.getUserIfExists(splitSetup.lnAddressOrPubKeyHex)
|
||||
user?.info?.lnAddress()
|
||||
}
|
||||
|
||||
if (lud16 != null) {
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = zapValue,
|
||||
message = message,
|
||||
nostrRequest = nostrZapRequest,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
val step = it - progressThisPayment
|
||||
progressThisPayment = it
|
||||
onProgressStep(step)
|
||||
},
|
||||
context = context,
|
||||
onSuccess = {
|
||||
onProgressStep(1 - progressThisPayment)
|
||||
onReady(Pair(zapValue, it))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
onError(
|
||||
context.getString(
|
||||
R.string.missing_lud16,
|
||||
),
|
||||
context.getString(
|
||||
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
user?.toBestDisplayName() ?: splitSetup.lnAddressOrPubKeyHex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,63 +312,4 @@ class ZapPaymentHandler(val account: Account) {
|
|||
onReady(null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerZap(
|
||||
lud16: String,
|
||||
note: Note,
|
||||
amount: Long,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayInvoiceThroughIntent: (String) -> Unit,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
overrideUser: User? = null,
|
||||
) {
|
||||
onProgress(0.05f)
|
||||
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
|
||||
onProgress(0.10f)
|
||||
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lud16,
|
||||
amount,
|
||||
message,
|
||||
zapRequestJson,
|
||||
onSuccess = {
|
||||
onProgress(0.7f)
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = it,
|
||||
note,
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_dialog_pay_invoice_error),
|
||||
context.getString(
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString() ?: "Error parsing error message",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
}
|
||||
},
|
||||
)
|
||||
onProgress(0.8f)
|
||||
} else {
|
||||
onPayInvoiceThroughIntent(it)
|
||||
onProgress(0f)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.service.HttpClientManager
|
|||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import com.vitorpamplona.quartz.encoders.toLnUrl
|
||||
import okhttp3.Request
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
@ -151,20 +150,6 @@ class LightningAddressResolver() {
|
|||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(
|
||||
lnaddress: String,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = { onSuccess(it.toByteArray().toLnUrl()) },
|
||||
onError = onError,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(
|
||||
lnaddress: String,
|
||||
milliSats: Long,
|
||||
|
@ -190,7 +175,8 @@ class LightningAddressResolver() {
|
|||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup,
|
||||
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
null
|
||||
|
@ -202,7 +188,8 @@ class LightningAddressResolver() {
|
|||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
|
||||
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -227,7 +214,8 @@ class LightningAddressResolver() {
|
|||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup,
|
||||
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
null
|
||||
|
@ -268,7 +256,8 @@ class LightningAddressResolver() {
|
|||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
|
||||
lnaddress,
|
||||
reason,
|
||||
),
|
||||
)
|
||||
|
@ -279,7 +268,8 @@ class LightningAddressResolver() {
|
|||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications
|
|||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
@ -45,6 +46,7 @@ import java.math.BigDecimal
|
|||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
suspend fun consume(event: GiftWrapEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Notification Arrived")
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (!notificationManager().areNotificationsEnabled()) return
|
||||
|
||||
|
@ -64,15 +66,26 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||
account: Account,
|
||||
) {
|
||||
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
val consumed = LocalCache.hasConsumed(notificationEvent)
|
||||
val verified = LocalCache.justVerify(notificationEvent)
|
||||
Log.d("EventNotificationConsumer", "New Notification Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified")
|
||||
if (!consumed && verified) {
|
||||
Log.d("EventNotificationConsumer", "New Notification was verified")
|
||||
unwrapAndConsume(notificationEvent, account) { innerEvent ->
|
||||
|
||||
unwrapAndConsume(notificationEvent, account) { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
Log.d("EventNotificationConsumer", "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
|
||||
if (!consumed) {
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Nip-04 DM to Notify")
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Zap to Notify")
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -84,6 +97,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||
onReady: (Event) -> Unit,
|
||||
) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (LocalCache.hasConsumed(event)) return
|
||||
|
||||
when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
|
@ -91,9 +105,11 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||
}
|
||||
is SealedGossipEvent -> {
|
||||
event.cachedGossip(account.signer) {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
if (!LocalCache.hasConsumed(it)) {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
|
@ -108,7 +124,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||
acc: Account,
|
||||
) {
|
||||
if (
|
||||
event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() && // old event being re-broadcasted
|
||||
event.pubKey != acc.userProfile().pubkeyHex
|
||||
) { // from the user
|
||||
|
||||
|
@ -148,7 +164,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
@ -187,7 +203,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
val noteZapped =
|
||||
|
@ -195,7 +211,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
|||
|
||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||
|
||||
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
if (event.isTaggedUser(acc.userProfile().pubkeyHex)) {
|
||||
val amount = showAmount(event.amount)
|
||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||
acc.decryptZapContentAuthor(noteZapRequest) {
|
||||
|
|
|
@ -27,6 +27,7 @@ import android.util.LruCache
|
|||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.PositionInfo
|
||||
import androidx.media3.common.Player.STATE_IDLE
|
||||
import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
|
@ -143,6 +144,14 @@ class MultiPlayerPlaybackManager(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: PositionInfo,
|
||||
newPosition: PositionInfo,
|
||||
reason: Int,
|
||||
) {
|
||||
cachedPositions.add(uri, newPosition.positionMs)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -27,10 +27,11 @@ import android.util.Log
|
|||
import android.util.LruCache
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
object PlaybackClientController {
|
||||
var executorService = Executors.newCachedThreadPool()
|
||||
val cache = LruCache<Int, SessionToken>(1)
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
|
@ -67,7 +68,7 @@ object PlaybackClientController {
|
|||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor(),
|
||||
executorService,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
|
|
@ -23,62 +23,73 @@ package com.vitorpamplona.amethyst.service.playback
|
|||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class WssOrHttpFactory(httpClient: OkHttpClient) : MediaSource.Factory {
|
||||
@UnstableApi
|
||||
val http = DefaultMediaSourceFactory(OkHttpDataSource.Factory(httpClient))
|
||||
|
||||
@UnstableApi
|
||||
val wss = DefaultMediaSourceFactory(WssStreamDataSource.Factory(httpClient))
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
http.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
wss.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
return this
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
http.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
wss.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
return this
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun getSupportedTypes(): IntArray {
|
||||
return http.supportedTypes
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
return if (mediaItem.mediaId.startsWith("wss")) {
|
||||
wss.createMediaSource(mediaItem)
|
||||
} else {
|
||||
http.createMediaSource(mediaItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var videoViewedPositionCache = VideoViewedPositionCache()
|
||||
|
||||
private var managerHls: MultiPlayerPlaybackManager? = null
|
||||
private var managerProgressive: MultiPlayerPlaybackManager? = null
|
||||
private var managerLocal: MultiPlayerPlaybackManager? = null
|
||||
private var managerAllInOne: MultiPlayerPlaybackManager? = null
|
||||
|
||||
fun newHslDataSource(): MediaSource.Factory {
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
|
||||
fun newAllInOneDataSource(): MediaSource.Factory {
|
||||
// This might be needed for live kit.
|
||||
// return WssOrHttpFactory(HttpClientManager.getHttpClient())
|
||||
return DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
|
||||
}
|
||||
|
||||
fun newProgressiveDataSource(): MediaSource.Factory {
|
||||
return ProgressiveMediaSource.Factory(
|
||||
(applicationContext as Amethyst).videoCache.get(HttpClientManager.getHttpClient()),
|
||||
)
|
||||
}
|
||||
|
||||
fun lazyHlsDS(): MultiPlayerPlaybackManager {
|
||||
managerHls?.let {
|
||||
fun lazyDS(): MultiPlayerPlaybackManager {
|
||||
managerAllInOne?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerHls = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
|
||||
managerProgressive?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val newInstance =
|
||||
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
fun lazyLocalDS(): MultiPlayerPlaybackManager {
|
||||
managerLocal?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
|
||||
managerLocal = newInstance
|
||||
val newInstance = MultiPlayerPlaybackManager(newAllInOneDataSource(), videoViewedPositionCache)
|
||||
managerAllInOne = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
|
@ -94,15 +105,11 @@ class PlaybackService : MediaSessionService() {
|
|||
}
|
||||
|
||||
private fun onProxyUpdated() {
|
||||
val toDestroyHls = managerHls
|
||||
val toDestroyProgressive = managerProgressive
|
||||
val toDestroyAllInOne = managerAllInOne
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive =
|
||||
MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
managerAllInOne = MultiPlayerPlaybackManager(newAllInOneDataSource(), videoViewedPositionCache)
|
||||
|
||||
toDestroyHls?.releaseAppPlayers()
|
||||
toDestroyProgressive?.releaseAppPlayers()
|
||||
toDestroyAllInOne?.releaseAppPlayers()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
|
@ -116,23 +123,11 @@ class PlaybackService : MediaSessionService() {
|
|||
|
||||
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
|
||||
managerHls?.releaseAppPlayers()
|
||||
managerLocal?.releaseAppPlayers()
|
||||
managerProgressive?.releaseAppPlayers()
|
||||
managerAllInOne?.releaseAppPlayers()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
|
||||
return if (fileName.startsWith("file")) {
|
||||
lazyLocalDS()
|
||||
} else if (fileName.endsWith("m3u8")) {
|
||||
lazyHlsDS()
|
||||
} else {
|
||||
lazyProgressiveDS()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(
|
||||
session: MediaSession,
|
||||
startInForegroundRequired: Boolean,
|
||||
|
@ -141,38 +136,18 @@ class PlaybackService : MediaSessionService() {
|
|||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerHls?.playingContent()?.forEach {
|
||||
managerAllInOne?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerHls?.playingContent()?.forEach {
|
||||
managerAllInOne?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a MediaSession to link with the MediaController that is making
|
||||
|
@ -182,9 +157,9 @@ class PlaybackService : MediaSessionService() {
|
|||
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
|
||||
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
|
||||
|
||||
val manager = getAppropriateMediaSessionManager(uri)
|
||||
val manager = lazyDS()
|
||||
|
||||
return manager?.getMediaSession(
|
||||
return manager.getMediaSession(
|
||||
id,
|
||||
uri,
|
||||
callbackUri,
|
||||
|
|
|
@ -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.service.playback
|
||||
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import java.util.concurrent.ConcurrentSkipListSet
|
||||
|
||||
class WssDataStreamCollector : WebSocketListener() {
|
||||
private val wssData = ConcurrentSkipListSet<ByteString>()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
bytes: ByteString,
|
||||
) {
|
||||
wssData.add(bytes)
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
super.onClosing(webSocket, code, reason)
|
||||
wssData.removeAll(wssData)
|
||||
}
|
||||
|
||||
fun canStream(): Boolean {
|
||||
return wssData.size > 0
|
||||
}
|
||||
|
||||
fun getNextStream(): ByteString {
|
||||
return wssData.pollFirst()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* 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.playback
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.BaseDataSource
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import kotlin.math.min
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class WssStreamDataSource(val httpClient: OkHttpClient) : BaseDataSource(true) {
|
||||
val dataStreamCollector: WssDataStreamCollector = WssDataStreamCollector()
|
||||
var webSocketClient: WebSocket? = null
|
||||
|
||||
private var currentByteStream: ByteArray? = null
|
||||
private var currentPosition = 0
|
||||
private var remainingBytes = 0
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
// Form the request and open the socket.
|
||||
// Provide the listener
|
||||
// which collects the data for us (Previous class).
|
||||
webSocketClient =
|
||||
httpClient.newWebSocket(
|
||||
Request.Builder().apply {
|
||||
dataSpec.httpRequestHeaders.forEach { entry ->
|
||||
addHeader(entry.key, entry.value)
|
||||
}
|
||||
}.url(dataSpec.uri.toString()).build(),
|
||||
dataStreamCollector,
|
||||
)
|
||||
|
||||
return -1 // Return -1 as the size is unknown (streaming)
|
||||
}
|
||||
|
||||
override fun getUri(): Uri? {
|
||||
webSocketClient?.request()?.url?.let {
|
||||
return Uri.parse(it.toString())
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun read(
|
||||
target: ByteArray,
|
||||
offset: Int,
|
||||
length: Int,
|
||||
): Int {
|
||||
// return 0 (nothing read) when no data present...
|
||||
if (currentByteStream == null && !dataStreamCollector.canStream()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// parse one (data) ByteString at a time.
|
||||
// reset the current position and remaining bytes
|
||||
// for every new data
|
||||
if (currentByteStream == null) {
|
||||
currentByteStream = dataStreamCollector.getNextStream().toByteArray()
|
||||
currentPosition = 0
|
||||
remainingBytes = currentByteStream?.size ?: 0
|
||||
}
|
||||
|
||||
val readSize = min(length, remainingBytes)
|
||||
|
||||
currentByteStream?.copyInto(target, offset, currentPosition, currentPosition + readSize)
|
||||
currentPosition += readSize
|
||||
remainingBytes -= readSize
|
||||
|
||||
// once the data is read set currentByteStream to null
|
||||
// so the next data would be collected to process in next
|
||||
// iteration.
|
||||
if (remainingBytes == 0) {
|
||||
currentByteStream = null
|
||||
}
|
||||
|
||||
return readSize
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// close the socket and relase the resources
|
||||
webSocketClient?.cancel()
|
||||
}
|
||||
|
||||
// Factory class for DataSource
|
||||
class Factory(val okHttpClient: OkHttpClient) : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource = WssStreamDataSource(okHttpClient)
|
||||
}
|
||||
}
|
|
@ -21,18 +21,14 @@
|
|||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
suspend fun fetchUrlPreview(timeOut: Int = 30000) =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
fetch(timeOut)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
callback?.onFailed(t)
|
||||
}
|
||||
try {
|
||||
fetch(timeOut)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
callback?.onFailed(t)
|
||||
}
|
||||
|
||||
private suspend fun fetch(timeOut: Int = 30000) {
|
||||
|
|
|
@ -20,6 +20,8 @@
|
|||
*/
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTag
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -27,60 +29,39 @@ import kotlinx.coroutines.withContext
|
|||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import okio.BufferedSource
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.Options
|
||||
import java.nio.charset.Charset
|
||||
|
||||
private const val ELEMENT_TAG_META = "meta"
|
||||
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
private const val ATTRIBUTE_VALUE_NAME = "name"
|
||||
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
|
||||
private const val ATTRIBUTE_VALUE_CHARSET = "charset"
|
||||
private const val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
|
||||
|
||||
// for <meta itemprop=... to get title
|
||||
private val META_X_TITLE =
|
||||
arrayOf(
|
||||
"og:title",
|
||||
"\"og:title\"",
|
||||
"'og:title'",
|
||||
"name",
|
||||
"\"name\"",
|
||||
"'name'",
|
||||
"twitter:title",
|
||||
"\"twitter:title\"",
|
||||
"'twitter:title'",
|
||||
"title",
|
||||
"\"title\"",
|
||||
"'title'",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get description
|
||||
private val META_X_DESCRIPTION =
|
||||
arrayOf(
|
||||
"og:description",
|
||||
"\"og:description\"",
|
||||
"'og:description'",
|
||||
"description",
|
||||
"\"description\"",
|
||||
"'description'",
|
||||
"twitter:description",
|
||||
"\"twitter:description\"",
|
||||
"'twitter:description'",
|
||||
"description",
|
||||
"\"description\"",
|
||||
"'description'",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get image
|
||||
private val META_X_IMAGE =
|
||||
arrayOf(
|
||||
"og:image",
|
||||
"\"og:image\"",
|
||||
"'og:image'",
|
||||
"image",
|
||||
"\"image\"",
|
||||
"'image'",
|
||||
"twitter:image",
|
||||
"\"twitter:image\"",
|
||||
"'twitter:image'",
|
||||
"image",
|
||||
)
|
||||
|
||||
private const val CONTENT = "content"
|
||||
|
@ -95,14 +76,12 @@ suspend fun getDocument(
|
|||
checkNotInMainThread()
|
||||
if (it.isSuccessful) {
|
||||
val mimeType =
|
||||
it.headers.get("Content-Type")?.toMediaType()
|
||||
it.headers["Content-Type"]?.toMediaType()
|
||||
?: throw IllegalArgumentException(
|
||||
"Website returned unknown mimetype: ${it.headers.get("Content-Type")}",
|
||||
"Website returned unknown mimetype: ${it.headers["Content-Type"]}",
|
||||
)
|
||||
|
||||
if (mimeType.type == "text" && mimeType.subtype == "html") {
|
||||
val document = Jsoup.parse(it.body.string())
|
||||
parseHtml(url, document, mimeType)
|
||||
parseHtml(url, it.body.source(), mimeType)
|
||||
} else if (mimeType.type == "image") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else if (mimeType.type == "video") {
|
||||
|
@ -120,65 +99,141 @@ suspend fun getDocument(
|
|||
|
||||
suspend fun parseHtml(
|
||||
url: String,
|
||||
document: Document,
|
||||
source: BufferedSource,
|
||||
type: MediaType,
|
||||
): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
|
||||
// sniff charset from Content-Type header or BOM
|
||||
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
|
||||
if (sniffedCharset != null) {
|
||||
val metaTags = MetaTagsParser.parse(source.readByteArray().toString(sniffedCharset))
|
||||
return@withContext extractUrlInfo(url, metaTags, type)
|
||||
}
|
||||
|
||||
var title: String = ""
|
||||
var description: String = ""
|
||||
var image: String = ""
|
||||
// if sniffing was failed, detect charset from content
|
||||
val bodyBytes = source.readByteArray()
|
||||
val charset = detectCharset(bodyBytes)
|
||||
val metaTags = MetaTagsParser.parse(bodyBytes.toString(charset))
|
||||
return@withContext extractUrlInfo(url, metaTags, type)
|
||||
}
|
||||
|
||||
metaTags.forEach {
|
||||
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
// taken from okhttp
|
||||
private val UNICODE_BOMS =
|
||||
Options.of(
|
||||
// UTF-8
|
||||
"efbbbf".decodeHex(),
|
||||
// UTF-16BE
|
||||
"feff".decodeHex(),
|
||||
// UTF-16LE
|
||||
"fffe".decodeHex(),
|
||||
// UTF-32BE
|
||||
"0000ffff".decodeHex(),
|
||||
// UTF-32LE
|
||||
"ffff0000".decodeHex(),
|
||||
)
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
private fun BufferedSource.readBomAsCharset(): Charset? {
|
||||
return when (select(UNICODE_BOMS)) {
|
||||
0 -> Charsets.UTF_8
|
||||
1 -> Charsets.UTF_16BE
|
||||
2 -> Charsets.UTF_16LE
|
||||
3 -> Charsets.UTF_32BE
|
||||
4 -> Charsets.UTF_32LE
|
||||
-1 -> null
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return@withContext UrlInfoItem(url, title, description, image, type)
|
||||
private fun detectCharset(bodyBytes: ByteArray): Charset {
|
||||
// try to detect charset from meta tags parsed from first 1024 bytes of body
|
||||
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
|
||||
val metaTags = MetaTagsParser.parse(firstPart)
|
||||
metaTags.forEach { meta ->
|
||||
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
|
||||
if (charsetAttr.isNotEmpty()) {
|
||||
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
return@withContext UrlInfoItem(url, title, description, image, type)
|
||||
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
|
||||
RE_CONTENT_TYPE_CHARSET.find(meta.attr(CONTENT))
|
||||
?.let {
|
||||
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
|
||||
}?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
// defaults to UTF-8
|
||||
return Charset.forName("utf-8")
|
||||
}
|
||||
|
||||
private fun extractUrlInfo(
|
||||
url: String,
|
||||
metaTags: Sequence<MetaTag>,
|
||||
type: MediaType,
|
||||
): UrlInfoItem {
|
||||
var title: String = ""
|
||||
var description: String = ""
|
||||
var image: String = ""
|
||||
|
||||
metaTags.forEach {
|
||||
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return UrlInfoItem(url, title, description, image, type)
|
||||
}
|
||||
}
|
||||
return UrlInfoItem(url, title, description, image, type)
|
||||
}
|
||||
|
|
|
@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.events.EventInterface
|
|||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
|
@ -98,7 +97,7 @@ object Client : RelayPool.Listener {
|
|||
checkNotInMainThread()
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.sendFilter(subscriptionId)
|
||||
RelayPool.sendFilter(subscriptionId, filters)
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected(
|
||||
|
@ -125,45 +124,8 @@ object Client : RelayPool.Listener {
|
|||
} else if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
|
||||
|
||||
if (useConnectedRelayIfPresent.isNotEmpty()) {
|
||||
useConnectedRelayIfPresent.forEach { it.send(signedEvent) }
|
||||
} else {
|
||||
/** temporary connection */
|
||||
newSporadicRelay(
|
||||
relay,
|
||||
feedTypes,
|
||||
onConnected = { relay -> relay.send(signedEvent) },
|
||||
onDone = onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun newSporadicRelay(
|
||||
url: String,
|
||||
feedTypes: Set<FeedType>?,
|
||||
onConnected: (Relay) -> Unit,
|
||||
onDone: (() -> Unit)?,
|
||||
) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.connectAndRun {
|
||||
allSubscriptions().forEach { relay.sendFilter(requestId = it) }
|
||||
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(60000) // waits for a reply
|
||||
relay.disconnect()
|
||||
RelayPool.removeRelay(relay)
|
||||
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
}
|
||||
RelayPool.getOrCreateRelay(relay, feedTypes, onDone) {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -264,8 +226,8 @@ object Client : RelayPool.Listener {
|
|||
listeners = listeners.minus(listener)
|
||||
}
|
||||
|
||||
fun allSubscriptions(): Set<String> {
|
||||
return subscriptions.keys
|
||||
fun allSubscriptions(): Map<String, List<TypedFilter>> {
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
|
||||
|
|
|
@ -35,144 +35,29 @@ object Constants {
|
|||
|
||||
val defaultRelays =
|
||||
arrayOf(
|
||||
// Free relays for only DMs and Follows due to the amount of spam
|
||||
// Free relays for only DMs, Chats and Follows due to the amount of spam
|
||||
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.fmt.wiz.biz", read = true, write = false, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
// Chats
|
||||
RelaySetupInfo(
|
||||
"wss://nostr.bitcoiner.social",
|
||||
read = true,
|
||||
write = true,
|
||||
feedTypes = activeTypesChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://relay.nostr.bg",
|
||||
read = true,
|
||||
write = true,
|
||||
feedTypes = activeTypesChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://nostr.oxtr.dev",
|
||||
read = true,
|
||||
write = true,
|
||||
feedTypes = activeTypesChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://nostr-pub.wellorder.net",
|
||||
read = true,
|
||||
write = true,
|
||||
feedTypes = activeTypesChats,
|
||||
),
|
||||
// Global
|
||||
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
|
||||
// Less Reliable
|
||||
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true,
|
||||
// feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes
|
||||
// = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true,
|
||||
// feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes =
|
||||
// activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true,
|
||||
// feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes =
|
||||
// activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes
|
||||
// = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes =
|
||||
// activeTypes),
|
||||
// Paid relays
|
||||
RelaySetupInfo(
|
||||
"wss://relay.snort.social",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://relay.nostr.com.au",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://eden.nostr.land",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://nostr.milou.lol",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://puravida.nostr.land",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://nostr.wine",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://nostr.inosta.cc",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://atlas.nostr.land",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://relay.orangepill.dev",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo(
|
||||
"wss://relay.nostrati.com",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesGlobalChats,
|
||||
),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo(
|
||||
"wss://relay.nostr.band",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesSearch,
|
||||
),
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo(
|
||||
"wss://relay.noswhere.com",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesSearch,
|
||||
),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
)
|
||||
|
||||
val forcedRelayForSearch =
|
||||
arrayOf(
|
||||
RelaySetupInfo(
|
||||
"wss://relay.nostr.band",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesSearch,
|
||||
),
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo(
|
||||
"wss://relay.noswhere.com",
|
||||
read = true,
|
||||
write = false,
|
||||
feedTypes = activeTypesSearch,
|
||||
),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
)
|
||||
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
|
||||
}
|
||||
|
|
|
@ -50,6 +50,9 @@ enum class FeedType {
|
|||
val COMMON_FEED_TYPES =
|
||||
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
|
||||
val EVENT_FINDER_TYPES =
|
||||
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL)
|
||||
|
||||
class Relay(
|
||||
val url: String,
|
||||
val read: Boolean = true,
|
||||
|
@ -63,7 +66,12 @@ class Relay(
|
|||
const val RECONNECTING_IN_SECONDS = 60 * 3
|
||||
}
|
||||
|
||||
private val httpClient = HttpClientManager.getHttpClient()
|
||||
private val httpClient =
|
||||
if (url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")) {
|
||||
HttpClientManager.getHttpClient(false)
|
||||
} else {
|
||||
HttpClientManager.getHttpClient()
|
||||
}
|
||||
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
|
@ -82,6 +90,7 @@ class Relay(
|
|||
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
|
||||
|
||||
val authResponse = mutableMapOf<HexKey, Boolean>()
|
||||
val sendWhenReady = mutableListOf<EventInterface>()
|
||||
|
||||
fun register(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
|
@ -159,6 +168,13 @@ class Relay(
|
|||
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
|
||||
onConnected(this@Relay)
|
||||
|
||||
synchronized(sendWhenReady) {
|
||||
sendWhenReady.forEach {
|
||||
send(it)
|
||||
}
|
||||
sendWhenReady.clear()
|
||||
}
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) }
|
||||
}
|
||||
|
||||
|
@ -264,6 +280,7 @@ class Relay(
|
|||
val event = Event.fromJson(msgArray.get(2))
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
|
||||
|
||||
listeners.forEach {
|
||||
it.onEvent(
|
||||
this@Relay,
|
||||
|
@ -344,19 +361,23 @@ class Relay(
|
|||
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
|
||||
}
|
||||
|
||||
fun sendFilter(requestId: String) {
|
||||
fun sendFilter(
|
||||
requestId: String,
|
||||
filters: List<TypedFilter>,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (read) {
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
val filters =
|
||||
Client.getSubscriptionFilters(requestId).filter { filter ->
|
||||
val relayFilters =
|
||||
filters.filter { filter ->
|
||||
activeTypes.any { it in filter.types }
|
||||
}
|
||||
if (filters.isNotEmpty()) {
|
||||
|
||||
if (relayFilters.isNotEmpty()) {
|
||||
val request =
|
||||
filters.joinToStringLimited(
|
||||
relayFilters.joinToStringLimited(
|
||||
separator = ",",
|
||||
limit = 20,
|
||||
prefix = """["REQ","$requestId",""",
|
||||
|
@ -423,7 +444,9 @@ class Relay(
|
|||
|
||||
fun renewFilters() {
|
||||
// Force update all filters after AUTH.
|
||||
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it.key, it.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
|
@ -442,6 +465,10 @@ class Relay(
|
|||
if (isReady) {
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
} else {
|
||||
synchronized(sendWhenReady) {
|
||||
sendWhenReady.add(signedEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// sends all filters after connection is successful.
|
||||
|
@ -452,7 +479,7 @@ class Relay(
|
|||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach { sendFilter(requestId = it) }
|
||||
renewFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,10 +24,15 @@ import androidx.compose.runtime.Immutable
|
|||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||
|
@ -58,6 +63,57 @@ object RelayPool : Relay.Listener {
|
|||
return relays.filter { it.url == url }
|
||||
}
|
||||
|
||||
fun getOrCreateRelay(
|
||||
url: String,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
onDone: (() -> Unit)? = null,
|
||||
whenConnected: (Relay) -> Unit,
|
||||
) {
|
||||
synchronized(this) {
|
||||
val matching = getRelays(url)
|
||||
if (matching.isNotEmpty()) {
|
||||
matching.forEach { whenConnected(it) }
|
||||
} else {
|
||||
/** temporary connection */
|
||||
newSporadicRelay(
|
||||
url,
|
||||
feedTypes,
|
||||
onConnected = whenConnected,
|
||||
onDone = onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(
|
||||
url: String,
|
||||
feedTypes: Set<FeedType>?,
|
||||
onConnected: (Relay) -> Unit,
|
||||
onDone: (() -> Unit)?,
|
||||
) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
addRelay(relay)
|
||||
|
||||
relay.connectAndRun {
|
||||
Client.allSubscriptions().forEach {
|
||||
relay.sendFilter(it.key, it.value)
|
||||
}
|
||||
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(60000) // waits for a reply
|
||||
relay.disconnect()
|
||||
removeRelay(relay)
|
||||
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRelays(relayList: List<Relay>) {
|
||||
if (!relayList.isNullOrEmpty()) {
|
||||
relayList.forEach { addRelay(it) }
|
||||
|
@ -77,8 +133,13 @@ object RelayPool : Relay.Listener {
|
|||
relays.forEach { it.connect() }
|
||||
}
|
||||
|
||||
fun sendFilter(subscriptionId: String) {
|
||||
relays.forEach { it.sendFilter(subscriptionId) }
|
||||
fun sendFilter(
|
||||
subscriptionId: String,
|
||||
filters: List<TypedFilter>,
|
||||
) {
|
||||
relays.forEach { relay ->
|
||||
relay.sendFilter(subscriptionId, filters)
|
||||
}
|
||||
}
|
||||
|
||||
fun connectAndSendFiltersIfDisconnected() {
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* 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
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@Composable
|
||||
fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
|
||||
val displayFeatures = calculateDisplayFeatures(act)
|
||||
val windowSizeClass = calculateWindowSizeClass(act)
|
||||
|
||||
LaunchedEffect(key1 = sharedPreferencesViewModel) {
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(act.isOnMobileDataState) {
|
||||
sharedPreferencesViewModel.updateConnectivityStatusState(act.isOnMobileDataState)
|
||||
}
|
||||
|
||||
return sharedPreferencesViewModel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppScreen(
|
||||
sharedPreferencesViewModel: SharedPreferencesViewModel,
|
||||
serviceManager: ServiceManager,
|
||||
) {
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
accountStateViewModel.serviceManager = serviceManager
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,16 +33,7 @@ import androidx.activity.compose.setContent
|
|||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
|
@ -53,10 +44,6 @@ import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
|
|||
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.debugState
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
|
@ -76,14 +63,13 @@ import java.util.Timer
|
|||
import kotlin.concurrent.schedule
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val isOnMobileDataState = mutableStateOf(false)
|
||||
val isOnMobileDataState = mutableStateOf(false)
|
||||
private val isOnWifiDataState = mutableStateOf(false)
|
||||
|
||||
// Service Manager is only active when the activity is active.
|
||||
val serviceManager = ServiceManager()
|
||||
private var shouldPauseService = true
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -91,36 +77,8 @@ class MainActivity : AppCompatActivity() {
|
|||
Log.d("Lifetime Event", "MainActivity.onCreate")
|
||||
|
||||
setContent {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
|
||||
val displayFeatures = calculateDisplayFeatures(this)
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
|
||||
LaunchedEffect(key1 = sharedPreferencesViewModel) {
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(isOnMobileDataState) {
|
||||
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
|
||||
}
|
||||
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
accountStateViewModel.serviceManager = serviceManager
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
|
||||
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel, serviceManager = serviceManager)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -46,6 +46,7 @@ import androidx.compose.foundation.verticalScroll
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
|
@ -87,7 +88,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
|
@ -98,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
|||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
|
@ -268,6 +270,7 @@ fun EditPostView(
|
|||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = 1,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
|
@ -312,11 +315,12 @@ fun EditPostView(
|
|||
val backgroundColor = remember { mutableStateOf(bgColor) }
|
||||
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
word = myUrlPreview,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
|
@ -446,6 +450,9 @@ fun ShowUserSuggestionListForEdit(
|
|||
key = { _, item -> item.pubkeyHex },
|
||||
) { _, item ->
|
||||
UserLine(item, accountViewModel) { editPostViewModel.autocompleteWithUser(item) }
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,8 +31,8 @@ import androidx.compose.ui.text.TextRange
|
|||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
@ -257,7 +257,7 @@ open class EditPostViewModel() : ViewModel() {
|
|||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions =
|
||||
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
|
||||
.reversed()
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import java.net.HttpURLConnection
|
||||
|
@ -36,7 +37,12 @@ class ImageDownloader {
|
|||
try {
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc = url.openConnection() as HttpURLConnection
|
||||
var huc =
|
||||
if (HttpClientManager.getDefaultProxy() != null) {
|
||||
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
huc.instanceFollowRedirects = true
|
||||
var responseCode = huc.responseCode
|
||||
|
||||
|
@ -45,7 +51,12 @@ class ImageDownloader {
|
|||
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc = url.openConnection() as HttpURLConnection
|
||||
huc =
|
||||
if (HttpClientManager.getDefaultProxy() != null) {
|
||||
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
responseCode = huc.responseCode
|
||||
}
|
||||
|
||||
|
|
|
@ -357,6 +357,10 @@ private fun RenderSearchResults(
|
|||
|
||||
searchBarViewModel.clear()
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
|
@ -367,6 +371,10 @@ private fun RenderSearchResults(
|
|||
nav("Channel/${item.idHex}")
|
||||
searchBarViewModel.clear()
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -404,39 +412,30 @@ fun UserComposeForChat(
|
|||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.clickable(
|
||||
onClick = onClick,
|
||||
).padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp,
|
||||
bottom = 10.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f),
|
||||
) {
|
||||
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) }
|
||||
|
||||
DisplayUserAboutInfo(baseUser)
|
||||
}
|
||||
DisplayUserAboutInfo(baseUser)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -146,9 +146,9 @@ class NewMessageTagger(
|
|||
|
||||
fun getNostrAddress(
|
||||
bechAddress: String,
|
||||
restOfTheWord: String,
|
||||
restOfTheWord: String?,
|
||||
): String {
|
||||
return if (restOfTheWord.isEmpty()) {
|
||||
return if (restOfTheWord.isNullOrEmpty()) {
|
||||
"nostr:$bechAddress"
|
||||
} else {
|
||||
if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) {
|
||||
|
@ -159,7 +159,7 @@ class NewMessageTagger(
|
|||
}
|
||||
}
|
||||
|
||||
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String)
|
||||
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String?)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
var key = mightBeAKey
|
||||
|
@ -181,7 +181,7 @@ class NewMessageTagger(
|
|||
val pubkey =
|
||||
Nip19Bech32.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
|
@ -192,7 +192,7 @@ class NewMessageTagger(
|
|||
|
||||
val pubkey = Nip19Bech32.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
|
@ -203,7 +203,7 @@ class NewMessageTagger(
|
|||
|
||||
val noteId = Nip19Bech32.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteId, restOfWord)
|
||||
return DirtyKeyInfo(noteId, restOfWord.ifEmpty { null })
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19Bech32.uriToRoute(key) ?: return null
|
||||
|
||||
|
|
|
@ -45,7 +45,9 @@ fun NewPollOption(
|
|||
Row {
|
||||
val deleteIcon: @Composable (() -> Unit) = {
|
||||
IconButton(
|
||||
onClick = { pollViewModel.pollOptions.remove(optionIndex) },
|
||||
onClick = {
|
||||
pollViewModel.removePollOption(optionIndex)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
|
@ -57,7 +59,9 @@ fun NewPollOption(
|
|||
OutlinedTextField(
|
||||
modifier = Modifier.weight(1F),
|
||||
value = pollViewModel.pollOptions[optionIndex] ?: "",
|
||||
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
|
||||
onValueChange = {
|
||||
pollViewModel.updatePollOption(optionIndex, it)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
|
||||
|
|
|
@ -56,8 +56,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.filled.LocationOff
|
||||
|
@ -66,7 +64,6 @@ import androidx.compose.material.icons.filled.Sell
|
|||
import androidx.compose.material.icons.filled.ShowChart
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
|
@ -128,7 +125,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
|||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
|
@ -147,6 +144,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
|||
import com.vitorpamplona.amethyst.ui.note.PollIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList
|
||||
|
@ -169,18 +167,21 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
|||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.Math.round
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun NewPostView(
|
||||
onClose: () -> Unit,
|
||||
|
@ -188,6 +189,7 @@ fun NewPostView(
|
|||
quote: Note? = null,
|
||||
fork: Note? = null,
|
||||
version: Note? = null,
|
||||
draft: Note? = null,
|
||||
enableMessageInterface: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
|
@ -202,10 +204,21 @@ fun NewPostView(
|
|||
var showRelaysDialog by remember { mutableStateOf(false) }
|
||||
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
|
||||
|
||||
LaunchedEffect(key1 = postViewModel.draftTag) {
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.draftTextChanges
|
||||
.receiveAsFlow()
|
||||
.debounce(1000)
|
||||
.collectLatest {
|
||||
postViewModel.sendDraft(relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft)
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
|
@ -222,7 +235,12 @@ fun NewPostView(
|
|||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
onDismissRequest = {
|
||||
scope.launch {
|
||||
postViewModel.sendDraftSync(relayList = relayList)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
|
@ -281,8 +299,9 @@ fun NewPostView(
|
|||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
postViewModel.cancel()
|
||||
scope.launch {
|
||||
postViewModel.sendDraftSync(relayList = relayList)
|
||||
postViewModel.cancel()
|
||||
delay(100)
|
||||
onClose()
|
||||
}
|
||||
|
@ -339,6 +358,7 @@ fun NewPostView(
|
|||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = 1,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
|
@ -353,7 +373,7 @@ fun NewPostView(
|
|||
}
|
||||
}
|
||||
|
||||
if (enableMessageInterface) {
|
||||
if (postViewModel.wantsDirectMessage) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
|
@ -416,11 +436,12 @@ fun NewPostView(
|
|||
val backgroundColor = remember { mutableStateOf(bgColor) }
|
||||
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
word = myUrlPreview,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
|
@ -582,7 +603,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
|
|||
}
|
||||
|
||||
MarkAsSensitive(postViewModel) {
|
||||
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
|
||||
postViewModel.toggleMarkAsSensitive()
|
||||
}
|
||||
|
||||
AddGeoHash(postViewModel) {
|
||||
|
@ -828,7 +849,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
|
|||
|
||||
MyTextField(
|
||||
value = postViewModel.title,
|
||||
onValueChange = { postViewModel.title = it },
|
||||
onValueChange = {
|
||||
postViewModel.updateTitle(it)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
|
@ -864,13 +887,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.price,
|
||||
onValueChange = {
|
||||
runCatching {
|
||||
if (it.text.isEmpty()) {
|
||||
postViewModel.price = TextFieldValue("")
|
||||
} else if (it.text.toLongOrNull() != null) {
|
||||
postViewModel.price = it
|
||||
}
|
||||
}
|
||||
postViewModel.updatePrice(it)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
|
@ -935,7 +952,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
|
|||
TextSpinner(
|
||||
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
|
||||
options = conditionOptions,
|
||||
onSelect = { postViewModel.condition = conditionTypes[it].first },
|
||||
onSelect = {
|
||||
postViewModel.updateCondition(conditionTypes[it].first)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
|
@ -999,7 +1018,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
|
|||
categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second
|
||||
?: "",
|
||||
options = categoryOptions,
|
||||
onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) },
|
||||
onSelect = {
|
||||
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
|
@ -1034,7 +1055,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
|
|||
|
||||
MyTextField(
|
||||
value = postViewModel.locationText,
|
||||
onValueChange = { postViewModel.locationText = it },
|
||||
onValueChange = {
|
||||
postViewModel.updateLocation(it)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
|
@ -1073,37 +1096,18 @@ fun FowardZapTo(
|
|||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
}
|
||||
ZapSplitIcon()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.zap_split_title),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
modifier = Modifier.padding(horizontal = 10.dp).weight(1f),
|
||||
)
|
||||
|
||||
OutlinedButton(onClick = { postViewModel.updateZapFromText() }) {
|
||||
Text(text = stringResource(R.string.load_from_text))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
|
@ -1124,7 +1128,7 @@ fun FowardZapTo(
|
|||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
UsernameDisplay(splitItem.key, showPlayButton = false)
|
||||
UsernameDisplay(splitItem.key)
|
||||
Text(
|
||||
text = String.format("%.0f%%", splitItem.percentage * 100),
|
||||
maxLines = 1,
|
||||
|
@ -1139,7 +1143,7 @@ fun FowardZapTo(
|
|||
Slider(
|
||||
value = splitItem.percentage,
|
||||
onValueChange = { sliderValue ->
|
||||
val rounded = (round(sliderValue * 20)) / 20.0f
|
||||
val rounded = (round(sliderValue * 100)) / 100.0f
|
||||
postViewModel.updateZapPercentage(index, rounded)
|
||||
},
|
||||
modifier = Modifier.weight(1.5f),
|
||||
|
@ -1280,8 +1284,7 @@ fun Notifying(
|
|||
mentions.forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
innerUserState?.user?.let { myUser ->
|
||||
val tags =
|
||||
remember(innerUserState) { myUser.info?.latestMetadata?.tags?.toImmutableListOfLists() }
|
||||
val tags = myUser.info?.tags
|
||||
|
||||
Button(
|
||||
shape = ButtonBorder,
|
||||
|
@ -1436,50 +1439,10 @@ private fun ForwardZapTo(
|
|||
IconButton(
|
||||
onClick = { onClick() },
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp),
|
||||
) {
|
||||
if (!postViewModel.wantsForwardZapTo) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.add_zap_split),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.cancel_zap_split),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
}
|
||||
if (!postViewModel.wantsForwardZapTo) {
|
||||
ZapSplitIcon(tint = MaterialTheme.colorScheme.onBackground)
|
||||
} else {
|
||||
ZapSplitIcon(tint = BitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,8 +35,8 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
@ -49,12 +49,15 @@ import com.vitorpamplona.amethyst.service.relays.Relay
|
|||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
|
@ -69,10 +72,13 @@ import kotlinx.coroutines.CancellationException
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
|
||||
enum class UserSuggestionAnchor {
|
||||
MAIN_MESSAGE,
|
||||
|
@ -82,6 +88,8 @@ enum class UserSuggestionAnchor {
|
|||
|
||||
@Stable
|
||||
open class NewPostViewModel() : ViewModel() {
|
||||
var draftTag: String by mutableStateOf(UUID.randomUUID().toString())
|
||||
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
var account: Account? = null
|
||||
var requiresNIP24: Boolean = false
|
||||
|
@ -164,6 +172,8 @@ open class NewPostViewModel() : ViewModel() {
|
|||
// NIP24 Wrapped DMs / Group messages
|
||||
var nip24 by mutableStateOf(false)
|
||||
|
||||
val draftTextChanges = Channel<String>(Channel.CONFLATED)
|
||||
|
||||
fun lnAddress(): String? {
|
||||
return account?.userProfile()?.info?.lnAddress()
|
||||
}
|
||||
|
@ -182,134 +192,280 @@ open class NewPostViewModel() : ViewModel() {
|
|||
quote: Note?,
|
||||
fork: Note?,
|
||||
version: Note?,
|
||||
draft: Note?,
|
||||
) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
if (replyNote.event is BaseTextNoteEvent) {
|
||||
this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
} else {
|
||||
this.eTags = listOf(replyNote)
|
||||
}
|
||||
val noteEvent = draft?.event
|
||||
val noteAuthor = draft?.author
|
||||
|
||||
if (replyNote.event !is CommunityDefinitionEvent) {
|
||||
replyNote.author?.let { replyUser ->
|
||||
val currentMentions =
|
||||
(replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) }
|
||||
?: emptyList()
|
||||
|
||||
if (currentMentions.contains(replyUser)) {
|
||||
this.pTags = currentMentions
|
||||
} else {
|
||||
this.pTags = currentMentions.plus(replyUser)
|
||||
if (draft != null && noteEvent is DraftEvent && noteAuthor != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.createTempDraftNote(noteEvent) { innerNote ->
|
||||
if (innerNote != null) {
|
||||
val oldTag = (draft.event as? AddressableEvent)?.dTag()
|
||||
if (oldTag != null) {
|
||||
draftTag = oldTag
|
||||
}
|
||||
loadFromDraft(innerNote, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?: run {
|
||||
eTags = null
|
||||
pTags = null
|
||||
} else {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
if (replyNote.event is BaseTextNoteEvent) {
|
||||
this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
} else {
|
||||
this.eTags = listOf(replyNote)
|
||||
}
|
||||
|
||||
if (replyNote.event !is CommunityDefinitionEvent) {
|
||||
replyNote.author?.let { replyUser ->
|
||||
val currentMentions =
|
||||
(replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) }
|
||||
?: emptyList()
|
||||
|
||||
if (currentMentions.contains(replyUser)) {
|
||||
this.pTags = currentMentions
|
||||
} else {
|
||||
this.pTags = currentMentions.plus(replyUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?: run {
|
||||
eTags = null
|
||||
pTags = null
|
||||
}
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
it.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
|
||||
forwardZapTo.addItem(quotedUser)
|
||||
}
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
|
||||
forwardZapTo.addItem(accountViewModel.userProfile())
|
||||
}
|
||||
|
||||
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
|
||||
forwardZapTo.updatePercentage(pos, 0.9f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fork?.let {
|
||||
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
it.event?.isSensitive()?.let {
|
||||
if (it) wantsToMarkAsSensitive = true
|
||||
}
|
||||
|
||||
it.event?.zapraiserAmount()?.let {
|
||||
zapRaiserAmount = it
|
||||
}
|
||||
|
||||
it.event?.zapSplitSetup()?.let {
|
||||
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
|
||||
|
||||
it.forEach {
|
||||
if (!it.isLnAddress) {
|
||||
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only adds if it is not already set up.
|
||||
if (forwardZapTo.items.isEmpty()) {
|
||||
it.author?.let { forkedAuthor ->
|
||||
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
|
||||
|
||||
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
|
||||
forwardZapTo.updatePercentage(pos, 0.8f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it.author?.let {
|
||||
if (this.pTags == null) {
|
||||
this.pTags = listOf(it)
|
||||
} else if (this.pTags?.contains(it) != true) {
|
||||
this.pTags = listOf(it) + (this.pTags ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
forkedFromNote = it
|
||||
} ?: run {
|
||||
forkedFromNote = null
|
||||
}
|
||||
|
||||
if (!forwardZapTo.items.isEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromDraft(
|
||||
draft: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Log.d("draft", draft.event!!.toJson())
|
||||
val draftEvent = draft.event ?: return
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsToAddGeoHash = false
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount = null
|
||||
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
|
||||
forwardZapTo = Split()
|
||||
localfowardZapTo.forEach {
|
||||
val user = LocalCache.getOrCreateUser(it[1])
|
||||
val value = it.last().toFloatOrNull() ?: 0f
|
||||
forwardZapTo.addItem(user, value)
|
||||
}
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
it.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
|
||||
forwardZapTo.addItem(quotedUser)
|
||||
}
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
|
||||
forwardZapTo.addItem(accountViewModel.userProfile())
|
||||
}
|
||||
|
||||
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
|
||||
forwardZapTo.updatePercentage(pos, 0.9f)
|
||||
}
|
||||
}
|
||||
wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" }
|
||||
wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" }
|
||||
val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" }
|
||||
wantsZapraiser = zapraiser.isNotEmpty()
|
||||
zapRaiserAmount = null
|
||||
if (wantsZapraiser) {
|
||||
zapRaiserAmount = zapraiser.first()[1].toLongOrNull() ?: 0
|
||||
}
|
||||
|
||||
fork?.let {
|
||||
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
it.event?.isSensitive()?.let {
|
||||
if (it) wantsToMarkAsSensitive = true
|
||||
eTags =
|
||||
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull {
|
||||
val note = LocalCache.checkGetOrCreateNote(it[1])
|
||||
note
|
||||
}
|
||||
|
||||
it.event?.zapraiserAmount()?.let {
|
||||
zapRaiserAmount = it
|
||||
}
|
||||
|
||||
it.event?.zapSplitSetup()?.let {
|
||||
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
|
||||
|
||||
it.forEach {
|
||||
if (!it.isLnAddress) {
|
||||
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
|
||||
}
|
||||
if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) {
|
||||
pTags =
|
||||
draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map {
|
||||
LocalCache.getOrCreateUser(it[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Only adds if it is not already set up.
|
||||
if (forwardZapTo.items.isEmpty()) {
|
||||
it.author?.let { forkedAuthor ->
|
||||
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
|
||||
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
|
||||
|
||||
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
|
||||
forwardZapTo.updatePercentage(pos, 0.8f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it.author?.let {
|
||||
if (this.pTags == null) {
|
||||
this.pTags = listOf(it)
|
||||
} else if (this.pTags?.contains(it) != true) {
|
||||
this.pTags = listOf(it) + (this.pTags ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
forkedFromNote = it
|
||||
} ?: run {
|
||||
forkedFromNote = null
|
||||
}
|
||||
|
||||
if (!forwardZapTo.items.isEmpty()) {
|
||||
draftEvent.tags().filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it.get(3) == "fork" }.forEach {
|
||||
val note = LocalCache.checkGetOrCreateNote(it[1])
|
||||
forkedFromNote = note
|
||||
}
|
||||
|
||||
originalNote =
|
||||
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }.map {
|
||||
LocalCache.checkGetOrCreateNote(it[1])
|
||||
}.firstOrNull()
|
||||
|
||||
if (originalNote == null) {
|
||||
originalNote =
|
||||
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }.map {
|
||||
LocalCache.checkGetOrCreateNote(it[1])
|
||||
}.firstOrNull()
|
||||
}
|
||||
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
|
||||
if (forwardZapTo.items.isNotEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
val polls = draftEvent.tags().filter { it.size > 1 && it[0] == "poll_option" }
|
||||
wantsPoll = polls.isNotEmpty()
|
||||
|
||||
polls.forEach {
|
||||
pollOptions[it[1].toInt()] = it[2]
|
||||
}
|
||||
|
||||
val minMax = draftEvent.tags().filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") }
|
||||
minMax.forEach {
|
||||
if (it[0] == "value_maximum") {
|
||||
valueMaximum = it[1].toInt()
|
||||
} else if (it[0] == "value_minimum") {
|
||||
valueMinimum = it[1].toInt()
|
||||
}
|
||||
}
|
||||
|
||||
wantsProduct = draftEvent.kind() == 30402
|
||||
|
||||
title = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "title" }.map { it[1] }?.firstOrNull() ?: "")
|
||||
price = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "price" }.map { it[1] }?.firstOrNull() ?: "")
|
||||
category = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "t" }.map { it[1] }?.firstOrNull() ?: "")
|
||||
locationText = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "location" }.map { it[1] }?.firstOrNull() ?: "")
|
||||
condition = ClassifiedsEvent.CONDITION.entries.firstOrNull {
|
||||
it.value == draftEvent.tags().filter { it.size > 1 && it[0] == "condition" }.map { it[1] }.firstOrNull()
|
||||
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
|
||||
|
||||
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent
|
||||
|
||||
draftEvent.subject()?.let {
|
||||
subject = TextFieldValue()
|
||||
}
|
||||
|
||||
message =
|
||||
if (draftEvent is PrivateDmEvent) {
|
||||
val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
|
||||
toUsers = TextFieldValue("@$recepientNpub")
|
||||
TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "")
|
||||
} else {
|
||||
TextFieldValue(draftEvent.content())
|
||||
}
|
||||
|
||||
requiresNIP24 = draftEvent is ChatMessageEvent
|
||||
nip24 = draftEvent is ChatMessageEvent
|
||||
|
||||
if (draftEvent is ChatMessageEvent) {
|
||||
toUsers =
|
||||
TextFieldValue(
|
||||
draftEvent.recipientsPubKey().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<Relay>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
innerSendPost(relayList, null)
|
||||
accountViewModel?.deleteDraft(draftTag)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun innerSendPost(relayList: List<Relay>? = null) {
|
||||
fun sendDraft(relayList: List<Relay>? = null) {
|
||||
viewModelScope.launch {
|
||||
sendDraftSync(relayList)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync(relayList: List<Relay>? = null) {
|
||||
innerSendPost(relayList, draftTag)
|
||||
}
|
||||
|
||||
private suspend fun innerSendPost(
|
||||
relayList: List<Relay>? = null,
|
||||
localDraft: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (accountViewModel == null) {
|
||||
cancel()
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val tagger =
|
||||
NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!)
|
||||
val tagger = NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!)
|
||||
tagger.run()
|
||||
|
||||
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!)
|
||||
|
@ -363,6 +519,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
account?.sendChannelMessage(
|
||||
|
@ -375,6 +532,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
|
@ -388,6 +546,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (originalNote?.event is ChatMessageEvent) {
|
||||
val receivers =
|
||||
|
@ -409,6 +568,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (!dmUsers.isNullOrEmpty()) {
|
||||
if (nip24 || dmUsers.size > 1) {
|
||||
|
@ -423,6 +583,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
account?.sendPrivateMessage(
|
||||
|
@ -435,6 +596,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
} else if (originalNote?.event is GitIssueEvent) {
|
||||
|
@ -475,24 +637,26 @@ open class NewPostViewModel() : ViewModel() {
|
|||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(
|
||||
tagger.message,
|
||||
tagger.eTags,
|
||||
tagger.pTags,
|
||||
pollOptions,
|
||||
valueMaximum,
|
||||
valueMinimum,
|
||||
consensusThreshold,
|
||||
closedAt,
|
||||
zapReceiver,
|
||||
wantsToMarkAsSensitive,
|
||||
localZapRaiserAmount,
|
||||
relayList,
|
||||
geoHash,
|
||||
message = tagger.message,
|
||||
replyTo = tagger.eTags,
|
||||
mentions = tagger.pTags,
|
||||
pollOptions = pollOptions,
|
||||
valueMaximum = valueMaximum,
|
||||
valueMinimum = valueMinimum,
|
||||
consensusThreshold = consensusThreshold,
|
||||
closedAt = closedAt,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (wantsProduct) {
|
||||
account?.sendClassifieds(
|
||||
|
@ -511,6 +675,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
// adds markers
|
||||
|
@ -547,11 +712,10 @@ open class NewPostViewModel() : ViewModel() {
|
|||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
fun upload(
|
||||
|
@ -652,6 +816,9 @@ open class NewPostViewModel() : ViewModel() {
|
|||
|
||||
wantsProduct = false
|
||||
condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
|
||||
locationText = TextFieldValue("")
|
||||
title = TextFieldValue("")
|
||||
category = TextFieldValue("")
|
||||
price = TextFieldValue("")
|
||||
|
||||
wantsForwardZapTo = false
|
||||
|
@ -664,9 +831,17 @@ open class NewPostViewModel() : ViewModel() {
|
|||
userSuggestionAnchor = null
|
||||
userSuggestionsMainMessage = null
|
||||
|
||||
draftTag = UUID.randomUUID().toString()
|
||||
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
}
|
||||
|
||||
fun deleteDraft() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel?.deleteDraft(draftTag)
|
||||
}
|
||||
}
|
||||
|
||||
open fun findUrlInMessage(): String? {
|
||||
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
paragraph.split(' ').firstOrNull { word: String ->
|
||||
|
@ -679,6 +854,10 @@ open class NewPostViewModel() : ViewModel() {
|
|||
pTags = pTags?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
private fun saveDraft() {
|
||||
draftTextChanges.trySend("")
|
||||
}
|
||||
|
||||
open fun updateMessage(it: TextFieldValue) {
|
||||
message = it
|
||||
urlPreview = findUrlInMessage()
|
||||
|
@ -693,7 +872,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions =
|
||||
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
|
||||
.reversed()
|
||||
}
|
||||
} else {
|
||||
|
@ -701,6 +880,8 @@ open class NewPostViewModel() : ViewModel() {
|
|||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
open fun updateToUsers(it: TextFieldValue) {
|
||||
|
@ -716,7 +897,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions =
|
||||
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
|
||||
.reversed()
|
||||
}
|
||||
} else {
|
||||
|
@ -724,10 +905,12 @@ open class NewPostViewModel() : ViewModel() {
|
|||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
open fun updateSubject(it: TextFieldValue) {
|
||||
subject = it
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
open fun updateZapForwardTo(it: TextFieldValue) {
|
||||
|
@ -745,6 +928,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
compareBy(
|
||||
{ account?.isFollowing(it) },
|
||||
{ it.toBestDisplayName() },
|
||||
{ it.pubkeyHex },
|
||||
),
|
||||
)
|
||||
.reversed()
|
||||
|
@ -772,16 +956,6 @@ open class NewPostViewModel() : ViewModel() {
|
|||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.addItem(item)
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
/*
|
||||
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
forwardZapTo = item
|
||||
|
||||
forwardZapToEditting = TextFieldValue(
|
||||
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)*/
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
|
||||
val lastWord =
|
||||
toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
|
@ -799,6 +973,8 @@ open class NewPostViewModel() : ViewModel() {
|
|||
userSuggestionsMainMessage = null
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
|
||||
|
@ -869,6 +1045,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
|
||||
message = message.insertUrlAtCursor(imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
saveDraft()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
|
@ -913,6 +1090,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
saveDraft()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
|
@ -933,6 +1111,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
locUtil?.let {
|
||||
location =
|
||||
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
|
||||
saveDraft()
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
|
||||
}
|
||||
|
@ -957,6 +1136,9 @@ open class NewPostViewModel() : ViewModel() {
|
|||
} else {
|
||||
nip24 = !nip24
|
||||
}
|
||||
if (message.text.isNotBlank()) {
|
||||
saveDraft()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMinZapAmountForPoll(textMin: String) {
|
||||
|
@ -976,6 +1158,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
}
|
||||
|
||||
checkMinMax()
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun updateMaxZapAmountForPoll(textMax: String) {
|
||||
|
@ -995,6 +1178,7 @@ open class NewPostViewModel() : ViewModel() {
|
|||
}
|
||||
|
||||
checkMinMax()
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun checkMinMax() {
|
||||
|
@ -1013,6 +1197,72 @@ open class NewPostViewModel() : ViewModel() {
|
|||
) {
|
||||
forwardZapTo.updatePercentage(index, sliderValue)
|
||||
}
|
||||
|
||||
fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.items.any { it.key == taggedUser }) {
|
||||
forwardZapTo.addItem(taggedUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateZapRaiserAmount(newAmount: Long?) {
|
||||
zapRaiserAmount = newAmount
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun removePollOption(optionIndex: Int) {
|
||||
pollOptions.remove(optionIndex)
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun updatePollOption(
|
||||
optionIndex: Int,
|
||||
text: String,
|
||||
) {
|
||||
pollOptions[optionIndex] = text
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun toggleMarkAsSensitive() {
|
||||
wantsToMarkAsSensitive = !wantsToMarkAsSensitive
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun updateTitle(it: TextFieldValue) {
|
||||
title = it
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun updatePrice(it: TextFieldValue) {
|
||||
runCatching {
|
||||
if (it.text.isEmpty()) {
|
||||
price = TextFieldValue("")
|
||||
} else if (it.text.toLongOrNull() != null) {
|
||||
price = it
|
||||
}
|
||||
}
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun updateCondition(newCondition: ClassifiedsEvent.CONDITION) {
|
||||
condition = newCondition
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun updateCategory(value: TextFieldValue) {
|
||||
category = value
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
fun updateLocation(it: TextFieldValue) {
|
||||
locationText = it
|
||||
saveDraft()
|
||||
}
|
||||
}
|
||||
|
||||
enum class GeohashPrecision(val digits: Int) {
|
||||
|
|
|
@ -1337,8 +1337,17 @@ fun EditableServerConfig(
|
|||
onClick = {
|
||||
if (url.text.isNotBlank() && url.text != "/") {
|
||||
var addedWSS =
|
||||
if (!url.text.startsWith("wss://") && !url.text.startsWith("ws://")) "wss://${url.text}" else url.text
|
||||
if (!url.text.startsWith("wss://") && !url.text..startsWith("ws://")) {
|
||||
if (url.text.endsWith(".onion") || url.text.endsWith(".onion/")) {
|
||||
"ws://$url"
|
||||
} else {
|
||||
"wss://$url"
|
||||
}
|
||||
} else {
|
||||
url.text
|
||||
}
|
||||
if (url.text.endsWith("/")) addedWSS = addedWSS.dropLast(1)
|
||||
|
||||
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
|
||||
url = TextFieldValue("")
|
||||
write = true
|
||||
|
|
|
@ -68,7 +68,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
|||
|
||||
account.userProfile().let {
|
||||
// userName.value = it.bestUsername() ?: ""
|
||||
displayName.value = it.bestDisplayName() ?: ""
|
||||
displayName.value = it.info?.bestName() ?: ""
|
||||
about.value = it.info?.about ?: ""
|
||||
picture.value = it.info?.picture ?: ""
|
||||
banner.value = it.info?.banner ?: ""
|
||||
|
@ -82,7 +82,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
|||
mastodon.value = ""
|
||||
|
||||
// TODO: Validate Telegram input, somehow.
|
||||
it.info?.latestMetadata?.identityClaims()?.forEach {
|
||||
it.latestMetadata?.identityClaims()?.forEach {
|
||||
when (it) {
|
||||
is TwitterIdentity -> twitter.value = it.toProofUrl()
|
||||
is GitHubIdentity -> github.value = it.toProofUrl()
|
||||
|
|
|
@ -65,6 +65,7 @@ fun NotifyRequestDialog(
|
|||
TranslatableRichTextViewer(
|
||||
textContent,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
Modifier.fillMaxWidth(),
|
||||
EmptyTagList,
|
||||
background,
|
||||
|
|
|
@ -294,7 +294,6 @@ private fun DisplayOwnerInformation(
|
|||
UserCompose(
|
||||
baseUser = it,
|
||||
accountViewModel = accountViewModel,
|
||||
showDiviser = false,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -53,7 +53,6 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
@ -65,6 +64,8 @@ import androidx.core.content.ContextCompat.startActivity
|
|||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fasterxml.jackson.databind.node.TextNode
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.service.CachedCashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CashuToken
|
||||
|
@ -182,7 +183,7 @@ fun CashuPreview(
|
|||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
imageVector = CustomHashTagIcons.Cashu,
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified,
|
||||
|
@ -320,7 +321,7 @@ fun CashuPreviewNew(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
imageVector = CustomHashTagIcons.Cashu,
|
||||
null,
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = Color.Unspecified,
|
||||
|
|
|
@ -26,13 +26,18 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ClickableNoteTag(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val route = routeFor(baseNote, accountViewModel.userProfile())
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
|
||||
onClick = { nav("Note/${baseNote.idHex}") },
|
||||
|
|
|
@ -65,10 +65,10 @@ import com.vitorpamplona.quartz.encoders.HexKey
|
|||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
|
@ -119,7 +119,7 @@ fun LoadOrCreateNote(
|
|||
@Composable
|
||||
private fun LoadAndDisplayEvent(
|
||||
event: Event,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
|
@ -141,7 +141,7 @@ private fun LoadAndDisplayEvent(
|
|||
private fun DisplayEvent(
|
||||
hex: HexKey,
|
||||
kind: Int?,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
|
@ -164,7 +164,7 @@ private fun DisplayNoteLink(
|
|||
it: Note,
|
||||
hex: HexKey,
|
||||
kind: Int?,
|
||||
addedCharts: String,
|
||||
addedCharts: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
|
@ -218,7 +218,7 @@ private fun DisplayNoteLink(
|
|||
@Composable
|
||||
private fun DisplayAddress(
|
||||
nip19: Nip19Bech32.NAddress,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
|
@ -245,16 +245,22 @@ private fun DisplayAddress(
|
|||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
remember { "@${nip19.atag}$additionalChars" },
|
||||
)
|
||||
if (additionalChars != null) {
|
||||
Text(
|
||||
remember { "@${nip19.atag}$additionalChars" },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
remember { "@${nip19.atag}" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUser(
|
||||
public fun DisplayUser(
|
||||
userHex: HexKey,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
|
@ -274,39 +280,34 @@ private fun DisplayUser(
|
|||
userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) }
|
||||
|
||||
if (userBase == null) {
|
||||
Text(
|
||||
remember { "@${userHex}$additionalChars" },
|
||||
)
|
||||
if (additionalChars != null) {
|
||||
Text(
|
||||
remember { "@${userHex}$additionalChars" },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
remember { "@$userHex" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderUserAsClickableText(
|
||||
baseUser: User,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val route = remember { "User/${baseUser.pubkeyHex}" }
|
||||
val userState by baseUser.live().userMetadataInfo.observeAsState()
|
||||
|
||||
val userDisplayName by
|
||||
remember(userState) { derivedStateOf { userState?.user?.toBestDisplayName() } }
|
||||
|
||||
val userTags by
|
||||
remember(userState) {
|
||||
derivedStateOf { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
|
||||
}
|
||||
|
||||
userDisplayName?.let {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = it,
|
||||
suffix = additionalChars.ifBlank { null },
|
||||
maxLines = 1,
|
||||
route = route,
|
||||
nav = nav,
|
||||
tags = userTags,
|
||||
)
|
||||
}
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()),
|
||||
suffix = additionalChars?.ifBlank { null },
|
||||
maxLines = 1,
|
||||
route = "User/${baseUser.pubkeyHex}",
|
||||
nav = nav,
|
||||
tags = userState?.tags ?: EmptyTagList,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@ -661,7 +662,10 @@ fun ClickableInLineIconRenderer(
|
|||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(1.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
@ -743,7 +747,10 @@ fun InLineIconRenderer(
|
|||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 0.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
@ -44,7 +44,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ExpandableTextCutOffCalculator
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ExpandableTextCutOffCalculator
|
||||
import com.vitorpamplona.amethyst.ui.note.getGradient
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
|
@ -53,13 +53,14 @@ import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
|
|||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
object ShowFullTextCache {
|
||||
val cache = LruCache<String, Boolean>(20)
|
||||
val cache = LruCache<String, Boolean>(10)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpandableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
modifier: Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
|
@ -67,15 +68,16 @@ fun ExpandableRichTextViewer(
|
|||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var showFullText by remember {
|
||||
val cached = ShowFullTextCache.cache[id]
|
||||
if (cached == null) {
|
||||
ShowFullTextCache.cache.put(id, false)
|
||||
mutableStateOf(false)
|
||||
} else {
|
||||
mutableStateOf(cached)
|
||||
var showFullText by
|
||||
remember {
|
||||
val cached = ShowFullTextCache.cache[id]
|
||||
if (cached == null) {
|
||||
ShowFullTextCache.cache.put(id, false)
|
||||
mutableStateOf(false)
|
||||
} else {
|
||||
mutableStateOf(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) }
|
||||
|
||||
|
@ -94,6 +96,7 @@ fun ExpandableRichTextViewer(
|
|||
RichTextViewer(
|
||||
text,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
modifier.align(Alignment.TopStart),
|
||||
tags,
|
||||
backgroundColor,
|
||||
|
|
|
@ -44,13 +44,14 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
|
||||
import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser
|
||||
import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
|
||||
|
@ -135,7 +136,7 @@ fun InvoicePreview(
|
|||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
imageVector = CustomHashTagIcons.Lightning,
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified,
|
||||
|
|
|
@ -44,7 +44,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
|
@ -52,6 +51,8 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
|
@ -118,7 +119,7 @@ fun InvoiceRequest(
|
|||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
imageVector = CustomHashTagIcons.Lightning,
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified,
|
||||
|
|
|
@ -27,9 +27,8 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
|
@ -62,25 +61,7 @@ fun LoadUrlPreview(
|
|||
) { state ->
|
||||
when (state) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
if (state.previewInfo.mimeType.type == "image") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlImage(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else if (state.previewInfo.mimeType.type == "video") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlVideo(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
RenderLoaded(state, url, accountViewModel)
|
||||
}
|
||||
else -> {
|
||||
ClickableUrl(urlText, url)
|
||||
|
@ -89,3 +70,30 @@ fun LoadUrlPreview(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderLoaded(
|
||||
state: UrlPreviewState.Loaded,
|
||||
url: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (state.previewInfo.mimeType.type == "image") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlImage(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else if (state.previewInfo.mimeType.type == "video") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlVideo(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,209 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class MarkdownParser {
|
||||
private fun getDisplayNameAndNIP19FromTag(
|
||||
tag: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Pair<String, String>? {
|
||||
val matcher = RichTextParser.tagIndex.matcher(tag)
|
||||
val (index, suffix) =
|
||||
try {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("Tag Parser", "Couldn't link tag $tag", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
LocalCache.checkGetOrCreateNote(tag[1])?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun getDisplayNameFromNip19(nip19: Nip19Bech32.Entity): Pair<String, String>? {
|
||||
return when (nip19) {
|
||||
is Nip19Bech32.NSec -> null
|
||||
is Nip19Bech32.NPub -> {
|
||||
LocalCache.getUserIfExists(nip19.hex)?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NProfile -> {
|
||||
LocalCache.getUserIfExists(nip19.hex)?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.Note -> {
|
||||
LocalCache.getNoteIfExists(nip19.hex)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NEvent -> {
|
||||
LocalCache.getNoteIfExists(nip19.hex)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NEmbed -> {
|
||||
if (LocalCache.getNoteIfExists(nip19.event.id) == null) {
|
||||
LocalCache.verifyAndConsume(nip19.event, null)
|
||||
}
|
||||
|
||||
LocalCache.getNoteIfExists(nip19.event.id)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NRelay -> null
|
||||
is Nip19Bech32.NAddress -> {
|
||||
LocalCache.getAddressableNoteIfExists(nip19.atag)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun returnNIP19References(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
): List<Nip19Bech32.Entity> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val listOfReferences = mutableListOf<Nip19Bech32.Entity>()
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (RichTextParser.startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19Bech32.uriToRoute(word)
|
||||
parsedNip19?.let { listOfReferences.add(it.entity) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags?.lists?.forEach {
|
||||
if (it[0] == "p" && it.size > 1) {
|
||||
listOfReferences.add(Nip19Bech32.NProfile(it[1], listOfNotNull(it.getOrNull(2))))
|
||||
} else if (it[0] == "e" && it.size > 1) {
|
||||
listOfReferences.add(Nip19Bech32.NEvent(it[1], listOfNotNull(it.getOrNull(2)), null, null))
|
||||
} else if (it[0] == "a" && it.size > 1) {
|
||||
ATag.parseAtag(it[1], it.getOrNull(2))?.let { atag ->
|
||||
listOfReferences.add(Nip19Bech32.NAddress(it[1], listOfNotNull(atag.relay), atag.pubKeyHex, atag.kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return listOfReferences
|
||||
}
|
||||
|
||||
suspend fun returnMarkdownWithSpecialContent(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (RichTextParser.isValidURL(word)) {
|
||||
if (RichTextParser.isImageUrl(word)) {
|
||||
returnContent += "![]($word) "
|
||||
} else {
|
||||
returnContent += "[$word]($word) "
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
returnContent += "[$word](mailto:$word) "
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
returnContent += "[$word](tel:$word) "
|
||||
} else if (RichTextParser.startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19Bech32.uriToRoute(word)
|
||||
returnContent +=
|
||||
if (parsedNip19?.entity !== null) {
|
||||
val pair = getDisplayNameFromNip19(parsedNip19.entity)
|
||||
if (pair != null) {
|
||||
val (displayName, nip19) = pair
|
||||
"[$displayName](nostr:$nip19) "
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else if (word.startsWith("#")) {
|
||||
if (RichTextParser.tagIndex.matcher(word).matches() && tags != null) {
|
||||
val pair = getDisplayNameAndNIP19FromTag(word, tags)
|
||||
if (pair != null) {
|
||||
returnContent += "[${pair.first}](nostr:${pair.second}) "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else if (RichTextParser.hashTagsPattern.matcher(word).matches()) {
|
||||
val hashtagMatcher = RichTextParser.hashTagsPattern.matcher(word)
|
||||
|
||||
val (myTag, mySuffix) =
|
||||
try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (myTag != null) {
|
||||
returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
}
|
||||
returnContent += "\n"
|
||||
}
|
||||
return returnContent
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@
|
|||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
@ -29,12 +30,12 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
|
@ -51,10 +52,6 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFontFamilyResolver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
@ -66,61 +63,52 @@ import androidx.compose.ui.unit.LayoutDirection
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.material3.Material3RichText
|
||||
import com.vitorpamplona.amethyst.commons.BechSegment
|
||||
import com.vitorpamplona.amethyst.commons.CashuSegment
|
||||
import com.vitorpamplona.amethyst.commons.EmailSegment
|
||||
import com.vitorpamplona.amethyst.commons.EmojiSegment
|
||||
import com.vitorpamplona.amethyst.commons.HashIndexEventSegment
|
||||
import com.vitorpamplona.amethyst.commons.HashIndexUserSegment
|
||||
import com.vitorpamplona.amethyst.commons.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.ImageSegment
|
||||
import com.vitorpamplona.amethyst.commons.InvoiceSegment
|
||||
import com.vitorpamplona.amethyst.commons.LinkSegment
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.PhoneSegment
|
||||
import com.vitorpamplona.amethyst.commons.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.commons.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.SchemelessUrlSegment
|
||||
import com.vitorpamplona.amethyst.commons.Segment
|
||||
import com.vitorpamplona.amethyst.commons.WithdrawSegment
|
||||
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Segment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.HashtagIcon
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun isMarkdown(content: String): Boolean {
|
||||
return content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("**") ||
|
||||
content.contains("```") ||
|
||||
content.contains("](")
|
||||
}
|
||||
|
@ -129,6 +117,7 @@ fun isMarkdown(content: String): Boolean {
|
|||
fun RichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
modifier: Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
|
@ -137,9 +126,32 @@ fun RichTextViewer(
|
|||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (remember(content) { isMarkdown(content) }) {
|
||||
RenderContentAsMarkdown(content, tags, accountViewModel, nav)
|
||||
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
} else {
|
||||
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
|
||||
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderStrangeNamePreview() {
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
RenderRegular(
|
||||
"If you want to stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm can you here",
|
||||
EmptyTagList,
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
is BechSegment -> {
|
||||
Text(
|
||||
"FreeFrom Official \uD80C\uDD66",
|
||||
modifier = Modifier.border(1.dp, Color.Red),
|
||||
)
|
||||
}
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -277,6 +289,7 @@ private fun RenderRegular(
|
|||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
|
@ -287,6 +300,7 @@ private fun RenderRegular(
|
|||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
quotesLeft,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
@ -304,7 +318,7 @@ private fun RenderRegular(
|
|||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RenderRegular(
|
||||
fun RenderRegular(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
wordRenderer: @Composable (Segment, RichTextViewerState) -> Unit,
|
||||
|
@ -318,7 +332,7 @@ private fun RenderRegular(
|
|||
val textStyle =
|
||||
remember(currentTextStyle) {
|
||||
currentTextStyle.copy(
|
||||
lineHeight = 1.4.em,
|
||||
lineHeight = 1.3.em,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -344,17 +358,6 @@ private fun RenderRegular(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
// UrlPreviews and Images have a 5dp spacing down. This also adds the space to Text.
|
||||
val lastElement = state.paragraphs.lastOrNull()?.words?.lastOrNull()
|
||||
if (lastElement !is ImageSegment &&
|
||||
lastElement !is LinkSegment &&
|
||||
lastElement !is InvoiceSegment &&
|
||||
lastElement !is CashuSegment
|
||||
) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -394,10 +397,10 @@ private fun RenderWordWithoutPreview(
|
|||
is CashuSegment -> Text(word.segmentText)
|
||||
is EmailSegment -> ClickableEmail(word.segmentText)
|
||||
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
|
||||
is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav)
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
|
||||
is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
|
@ -408,6 +411,7 @@ private fun RenderWordWithPreview(
|
|||
word: Segment,
|
||||
state: RichTextViewerState,
|
||||
backgroundColor: MutableState<Color>,
|
||||
quotesLeft: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
|
@ -420,10 +424,10 @@ private fun RenderWordWithPreview(
|
|||
is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
|
||||
is EmailSegment -> ClickableEmail(word.segmentText)
|
||||
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||
is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
|
||||
is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
|
||||
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
|
||||
is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
|
@ -459,210 +463,28 @@ fun RenderCustomEmoji(
|
|||
)
|
||||
}
|
||||
|
||||
val markdownParseOptions =
|
||||
MarkdownParseOptions(
|
||||
autolink = true,
|
||||
isImage = { url -> RichTextParser.isImageOrVideoUrl(url) },
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RenderContentAsMarkdown(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val onClick =
|
||||
remember {
|
||||
{ link: String ->
|
||||
val route = uriToRoute(link)
|
||||
if (route != null) {
|
||||
nav(route)
|
||||
} else {
|
||||
runCatching { uri.openUri(link) }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
ProvideTextStyle(MarkdownTextStyle) {
|
||||
Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) {
|
||||
RefreshableContent(content, tags, accountViewModel) {
|
||||
Markdown(
|
||||
content = it,
|
||||
markdownParseOptions = markdownParseOptions,
|
||||
onLinkClicked = onClick,
|
||||
onMediaCompose = { title, destination ->
|
||||
ZoomableContentView(
|
||||
content =
|
||||
remember(destination, tags) {
|
||||
RichTextParser().parseMediaUrl(
|
||||
destination,
|
||||
tags ?: EmptyTagList,
|
||||
title.ifEmpty { null } ?: content,
|
||||
) ?: MediaUrlImage(url = destination, description = title.ifEmpty { null } ?: content)
|
||||
},
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefreshableContent(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
accountViewModel: AccountViewModel,
|
||||
onCompose: @Composable (String) -> Unit,
|
||||
) {
|
||||
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
|
||||
|
||||
ObserverAllNIP19References(content, tags, accountViewModel) {
|
||||
accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent ->
|
||||
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
|
||||
markdownWithSpecialContent = newMarkdownWithSpecialContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markdownWithSpecialContent?.let { onCompose(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserverAllNIP19References(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var nip19References by remember(content) { mutableStateOf<List<Nip19Bech32.Entity>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
accountViewModel.returnNIP19References(content, tags) {
|
||||
nip19References = it
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
nip19References.forEach { ObserveNIP19(it, accountViewModel, onRefresh) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveNIP19(
|
||||
entity: Nip19Bech32.Entity,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
when (val parsed = entity) {
|
||||
is Nip19Bech32.NPub -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NProfile -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.Note -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NEvent -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NEmbed -> ObserveNIP19Event(parsed.event.id, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.NAddress -> ObserveNIP19Event(parsed.atag, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.NSec -> {}
|
||||
is Nip19Bech32.NRelay -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveNIP19Event(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var baseNote by remember(hex) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(hex)) }
|
||||
|
||||
if (baseNote == null) {
|
||||
LaunchedEffect(key1 = hex) {
|
||||
accountViewModel.checkGetOrCreateNote(hex) { note ->
|
||||
launch(Dispatchers.Main) { baseNote = note }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseNote?.let { note -> ObserveNote(note, onRefresh) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveNote(
|
||||
note: Note,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val loadedNoteId by note.live().metadata.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = loadedNoteId) {
|
||||
if (loadedNoteId != null) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveNIP19User(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var baseUser by remember(hex) { mutableStateOf<User?>(accountViewModel.getUserIfExists(hex)) }
|
||||
|
||||
if (baseUser == null) {
|
||||
LaunchedEffect(key1 = hex) {
|
||||
accountViewModel.checkGetOrCreateUser(hex)?.let { user ->
|
||||
launch(Dispatchers.Main) { baseUser = user }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseUser?.let { user -> ObserveUser(user, onRefresh) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveUser(
|
||||
user: User,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val loadedUserMetaId by user.live().metadata.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = loadedUserMetaId) {
|
||||
if (loadedUserMetaId != null) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BechLink(
|
||||
word: String,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
|
||||
val loadedLink by produceCachedState(cache = accountViewModel.bechLinkCache, key = word)
|
||||
|
||||
if (loadedLink == null) {
|
||||
LaunchedEffect(key1 = word) {
|
||||
accountViewModel.parseNIP19(word) { loadedLink = it }
|
||||
}
|
||||
}
|
||||
val baseNote = loadedLink?.baseNote
|
||||
|
||||
if (canPreview && loadedLink?.baseNote != null) {
|
||||
if (canPreview && quotesLeft > 0 && baseNote != null) {
|
||||
Row {
|
||||
DisplayFullNote(
|
||||
loadedLink?.baseNote!!,
|
||||
accountViewModel,
|
||||
backgroundColor,
|
||||
nav,
|
||||
loadedLink?.nip19?.additionalChars?.ifBlank { null },
|
||||
note = baseNote,
|
||||
extraChars = loadedLink?.nip19?.additionalChars?.ifBlank { null },
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
} else if (loadedLink?.nip19 != null) {
|
||||
|
@ -682,18 +504,20 @@ fun BechLink(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayFullNote(
|
||||
it: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
fun DisplayFullNote(
|
||||
note: Note,
|
||||
extraChars: String?,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = MaterialTheme.colorScheme.innerPostModifier,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
isQuotedNote = true,
|
||||
nav = nav,
|
||||
)
|
||||
|
@ -712,62 +536,48 @@ fun HashTag(
|
|||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val background = MaterialTheme.colorScheme.onBackground
|
||||
val hashtagIcon: HashtagIcon? =
|
||||
remember(segment.segmentText) { checkForHashtagWithIcon(segment.hashtag, primary) }
|
||||
|
||||
val regularText = remember { SpanStyle(color = background) }
|
||||
val clickableTextStyle = remember { SpanStyle(color = primary) }
|
||||
val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(segment.hashtag)
|
||||
|
||||
val annotatedTermsString =
|
||||
remember(segment.segmentText) {
|
||||
buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
withStyle(SpanStyle(color = primary)) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
append("#${segment.hashtag}")
|
||||
pop()
|
||||
}
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
withStyle(clickableTextStyle) {
|
||||
withStyle(SpanStyle(color = primary)) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
pop()
|
||||
}
|
||||
}
|
||||
|
||||
segment.extras?.let { withStyle(regularText) { append(it) } }
|
||||
segment.extras?.let { withStyle(SpanStyle(color = background)) { append(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
val inlineContent =
|
||||
if (hashtagIcon != null) {
|
||||
mapOf("inlineContent" to InlineIcon(hashtagIcon))
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val pressIndicator = remember { Modifier.clickable { nav("Hashtag/${segment.hashtag}") } }
|
||||
|
||||
Text(
|
||||
text = annotatedTermsString,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent,
|
||||
modifier = remember { Modifier.clickable { nav("Hashtag/${segment.hashtag}") } },
|
||||
inlineContent =
|
||||
if (hashtagIcon != null) {
|
||||
mapOf("inlineContent" to InlineIcon(hashtagIcon))
|
||||
} else {
|
||||
emptyMap()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineIcon(hashtagIcon: HashtagIcon) =
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = Font17SP,
|
||||
height = Font17SP,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
),
|
||||
) {
|
||||
InlineTextContent(inlinePlaceholder) {
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
imageVector = hashtagIcon.icon,
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
tint = Color.Unspecified,
|
||||
modifier = hashtagIcon.modifier,
|
||||
)
|
||||
}
|
||||
|
@ -814,6 +624,7 @@ fun LoadNote(
|
|||
fun TagLink(
|
||||
word: HashIndexEventSegment,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
|
@ -827,6 +638,7 @@ fun TagLink(
|
|||
it,
|
||||
word.extras,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
accountViewModel,
|
||||
backgroundColor,
|
||||
nav,
|
||||
|
@ -841,21 +653,23 @@ private fun DisplayNoteFromTag(
|
|||
baseNote: Note,
|
||||
addedChars: String?,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
if (canPreview) {
|
||||
if (canPreview && quotesLeft > 0) {
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = MaterialTheme.colorScheme.innerPostModifier,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
ClickableNoteTag(baseNote, nav)
|
||||
ClickableNoteTag(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
addedChars?.ifBlank { null }?.let { Text(text = it) }
|
||||
|
@ -866,18 +680,14 @@ private fun DisplayUserFromTag(
|
|||
baseUser: User,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val route = remember { "User/${baseUser.pubkeyHex}" }
|
||||
val hex = remember { baseUser.pubkeyDisplayHex() }
|
||||
|
||||
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
|
||||
|
||||
Crossfade(targetState = meta, label = "DisplayUserFromTag") {
|
||||
Row {
|
||||
val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex }
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = displayName,
|
||||
clickablePart = remember(meta) { it?.bestName() ?: baseUser.pubkeyDisplayHex() },
|
||||
maxLines = 1,
|
||||
route = route,
|
||||
route = "User/${baseUser.pubkeyHex}",
|
||||
nav = nav,
|
||||
tags = it?.tags,
|
||||
)
|
||||
|
|
|
@ -30,7 +30,7 @@ import coil.fetch.Fetcher
|
|||
import coil.fetch.SourceResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.Robohash
|
||||
import com.vitorpamplona.amethyst.commons.robohash.Robohash
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
|
|
|
@ -49,7 +49,7 @@ import coil.fetch.FetchResult
|
|||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.theme.isLight
|
||||
import java.util.Base64
|
||||
|
|
|
@ -25,6 +25,7 @@ import androidx.compose.foundation.combinedClickable
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
@ -41,6 +42,7 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
|
@ -106,8 +108,8 @@ fun UrlPreviewCard(
|
|||
AsyncImage(
|
||||
model = previewInfo.imageUrlFullPath,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
|
|
@ -57,7 +57,6 @@ import androidx.compose.runtime.derivedStateOf
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
|
@ -91,6 +90,8 @@ import androidx.media3.session.MediaController
|
|||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.linc.audiowaveform.infiniteLinearGradient
|
||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
|
||||
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
|
||||
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
|
||||
|
@ -114,6 +115,7 @@ import kotlinx.coroutines.flow.conflate
|
|||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.abs
|
||||
|
||||
public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
|
||||
|
@ -328,6 +330,43 @@ fun VideoViewInner(
|
|||
}
|
||||
}
|
||||
|
||||
val mediaItemCache = MediaItemCache()
|
||||
|
||||
@Immutable
|
||||
data class MediaItemData(
|
||||
val videoUri: String,
|
||||
val authorName: String? = null,
|
||||
val title: String? = null,
|
||||
val artworkUri: String? = null,
|
||||
)
|
||||
|
||||
class MediaItemCache() : GenericBaseCache<MediaItemData, MediaItem>(20) {
|
||||
override suspend fun compute(data: MediaItemData): MediaItem? {
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(data.videoUri)
|
||||
.setUri(data.videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(data.authorName?.ifBlank { null })
|
||||
.setTitle(data.title?.ifBlank { null } ?: data.videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (data.artworkUri != null) {
|
||||
Uri.parse(data.artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
},
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GetMediaItem(
|
||||
videoUri: String,
|
||||
|
@ -336,51 +375,15 @@ fun GetMediaItem(
|
|||
authorName: String?,
|
||||
inner: @Composable (State<MediaItem>) -> Unit,
|
||||
) {
|
||||
val mediaItem =
|
||||
produceState<MediaItem?>(
|
||||
initialValue = null,
|
||||
key1 = videoUri,
|
||||
) {
|
||||
this.value =
|
||||
MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
},
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
val data = remember(videoUri) { MediaItemData(videoUri, title, artworkUri, authorName) }
|
||||
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
|
||||
|
||||
mediaItem.value?.let {
|
||||
mediaItem?.let {
|
||||
val myState = remember(videoUri) { mutableStateOf(it) }
|
||||
inner(myState)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class MediaControllerState {
|
||||
@Immutable object NotStarted : MediaControllerState()
|
||||
|
||||
@Immutable object Loading : MediaControllerState()
|
||||
|
||||
@Stable class Loaded(val instance: MediaController) : MediaControllerState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
fun GetVideoController(
|
||||
|
@ -392,14 +395,15 @@ fun GetVideoController(
|
|||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val onlyOnePreparing = AtomicBoolean()
|
||||
|
||||
val controller =
|
||||
remember(videoUri) {
|
||||
val globalMutex = keepPlayingMutex
|
||||
mutableStateOf<MediaControllerState>(
|
||||
if (videoUri == globalMutex?.currentMediaItem?.mediaId) {
|
||||
MediaControllerState.Loaded(globalMutex)
|
||||
mutableStateOf(
|
||||
if (videoUri == keepPlayingMutex?.currentMediaItem?.mediaId) {
|
||||
keepPlayingMutex
|
||||
} else {
|
||||
MediaControllerState.NotStarted
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -419,44 +423,47 @@ fun GetVideoController(
|
|||
DisposableEffect(key1 = videoUri) {
|
||||
// If it is not null, the user might have come back from a playing video, like clicking on
|
||||
// the notification of the video player.
|
||||
if (controller.value == MediaControllerState.NotStarted) {
|
||||
controller.value = MediaControllerState.Loading
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video $videoUri ")
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
|
||||
val newState = MediaControllerState.Loaded(it)
|
||||
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
newState.instance.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
newState.instance.volume = if (defaultToStart) 0f else 1f
|
||||
if (controller.value == null) {
|
||||
// If there is a connection, don't wait.
|
||||
if (!onlyOnePreparing.getAndSet(true)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video $videoUri ")
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
it.volume = if (defaultToStart) 0f else 1f
|
||||
}
|
||||
}
|
||||
|
||||
it.setMediaItem(mediaItem.value)
|
||||
it.prepare()
|
||||
|
||||
controller.value = it
|
||||
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
}
|
||||
|
||||
newState.instance.setMediaItem(mediaItem.value)
|
||||
newState.instance.prepare()
|
||||
|
||||
controller.value = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (controller.value is MediaControllerState.Loaded) {
|
||||
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
|
||||
} else {
|
||||
// has been loaded. prepare to play
|
||||
controller.value?.let {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
|
||||
Log.d("PlaybackService", "Preparing Existing Video $videoUri ")
|
||||
|
||||
if (it.isPlaying) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
|
@ -466,7 +473,10 @@ fun GetVideoController(
|
|||
it.volume = if (defaultToStart) 0f else 1f
|
||||
}
|
||||
|
||||
it.setMediaItem(mediaItem.value)
|
||||
if (mediaItem.value != it.currentMediaItem) {
|
||||
it.setMediaItem(mediaItem.value)
|
||||
}
|
||||
|
||||
it.prepare()
|
||||
}
|
||||
}
|
||||
|
@ -477,11 +487,11 @@ fun GetVideoController(
|
|||
GlobalScope.launch(Dispatchers.Main) {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
|
||||
controller.value?.let {
|
||||
it.stop()
|
||||
it.release()
|
||||
Log.d("PlaybackService", "Releasing Video $videoUri ")
|
||||
controller.value = MediaControllerState.NotStarted
|
||||
controller.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -496,39 +506,36 @@ fun GetVideoController(
|
|||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
// if the controller is null, restarts the controller with a new one
|
||||
// if the controller is not null, just continue playing what the controller was playing
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (controller.value == MediaControllerState.NotStarted) {
|
||||
controller.value = MediaControllerState.Loading
|
||||
|
||||
Log.d("PlaybackService", "Preparing Video from Resume $videoUri ")
|
||||
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
|
||||
val newState = MediaControllerState.Loaded(it)
|
||||
|
||||
// checks again to make sure no other thread has created a controller.
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
newState.instance.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
newState.instance.volume = if (defaultToStart) 0f else 1f
|
||||
if (controller.value == null) {
|
||||
if (!onlyOnePreparing.getAndSet(true)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video from Resume $videoUri ")
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
// checks again to make sure no other thread has created a controller.
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
it.volume = if (defaultToStart) 0f else 1f
|
||||
}
|
||||
}
|
||||
|
||||
it.setMediaItem(mediaItem.value)
|
||||
it.prepare()
|
||||
|
||||
controller.value = it
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
}
|
||||
|
||||
newState.instance.setMediaItem(mediaItem.value)
|
||||
newState.instance.prepare()
|
||||
|
||||
controller.value = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -538,11 +545,11 @@ fun GetVideoController(
|
|||
GlobalScope.launch(Dispatchers.Main) {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
|
||||
controller.value?.let {
|
||||
Log.d("PlaybackService", "Releasing Video from Pause $videoUri ")
|
||||
it.stop()
|
||||
it.release()
|
||||
controller.value = MediaControllerState.NotStarted
|
||||
controller.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -553,7 +560,9 @@ fun GetVideoController(
|
|||
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
(controller.value as? MediaControllerState.Loaded)?.let { inner(it.instance, keepPlaying) }
|
||||
controller.value?.let {
|
||||
inner(it, keepPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
// background playing mutex.
|
||||
|
@ -669,43 +678,6 @@ private fun RenderVideoPlayer(
|
|||
}
|
||||
}
|
||||
|
||||
val factory =
|
||||
remember(controller) {
|
||||
{ context: Context ->
|
||||
PlayerView(context).apply {
|
||||
player = controller
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
setShutterBackgroundColor(Color.Transparent.toArgb())
|
||||
controllerAutoShow = false
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
resizeMode =
|
||||
if (maxHeight.isFinite) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
controller.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener { visible ->
|
||||
controllerVisible.value = visible == View.VISIBLE
|
||||
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ratio = remember { aspectRatio(dimensions) }
|
||||
|
||||
if (ratio != null) {
|
||||
|
@ -719,7 +691,39 @@ private fun RenderVideoPlayer(
|
|||
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = factory,
|
||||
factory = { context: Context ->
|
||||
PlayerView(context).apply {
|
||||
player = controller
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
setShutterBackgroundColor(Color.Transparent.toArgb())
|
||||
controllerAutoShow = false
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
resizeMode =
|
||||
if (maxHeight.isFinite) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
controller.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener { visible ->
|
||||
controllerVisible.value = visible == View.VISIBLE
|
||||
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) }
|
||||
|
@ -892,13 +896,17 @@ fun ControlWhenPlayerIsActive(
|
|||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
// doesn't consider the mutex because the screen can turn off if the video
|
||||
// being played in the mutex is not visible.
|
||||
view.keepScreenOn = isPlaying
|
||||
if (view.keepScreenOn != isPlaying) {
|
||||
view.keepScreenOn = isPlaying
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.addListener(listener)
|
||||
onDispose {
|
||||
view.keepScreenOn = false
|
||||
if (view.keepScreenOn) {
|
||||
view.keepScreenOn = false
|
||||
}
|
||||
controller.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,13 +34,14 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
|
@ -59,7 +60,7 @@ fun ZapRaiserRequest(
|
|||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
imageVector = CustomHashTagIcons.Lightning,
|
||||
null,
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified,
|
||||
|
@ -93,9 +94,9 @@ fun ZapRaiserRequest(
|
|||
onValueChange = {
|
||||
runCatching {
|
||||
if (it.isEmpty()) {
|
||||
newPostViewModel.zapRaiserAmount = null
|
||||
newPostViewModel.updateZapRaiserAmount(null)
|
||||
} else {
|
||||
newPostViewModel.zapRaiserAmount = it.toLongOrNull()
|
||||
newPostViewModel.updateZapRaiserAmount(it.toLongOrNull())
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -106,13 +106,13 @@ import coil.compose.AsyncImage
|
|||
import coil.compose.AsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.MediaLocalImage
|
||||
import com.vitorpamplona.amethyst.commons.MediaLocalVideo
|
||||
import com.vitorpamplona.amethyst.commons.MediaPreloadedContent
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
|
||||
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.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
|
@ -141,6 +141,7 @@ import kotlinx.coroutines.CancellationException
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
|
@ -501,8 +502,6 @@ private fun AddedImageFeatures(
|
|||
ImageUrlWithDownloadButton(content.url, showImage)
|
||||
}
|
||||
} else {
|
||||
var verifiedHash by remember(content.url) { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
when (painter.value) {
|
||||
null,
|
||||
is AsyncImagePainter.State.Loading,
|
||||
|
@ -528,24 +527,35 @@ private fun AddedImageFeatures(
|
|||
}
|
||||
}
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
if (content.hash != null) {
|
||||
LaunchedEffect(key1 = content.url) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newVerifiedHash = verifyHash(content)
|
||||
if (newVerifiedHash != verifiedHash) {
|
||||
verifiedHash = newVerifiedHash
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) }
|
||||
ShowHash(content, verifiedModifier)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowHash(
|
||||
content: MediaUrlContent,
|
||||
verifiedModifier: Modifier,
|
||||
) {
|
||||
var verifiedHash by remember(content.url) { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
if (content.hash != null) {
|
||||
LaunchedEffect(key1 = content.url) {
|
||||
val newVerifiedHash =
|
||||
withContext(Dispatchers.IO) {
|
||||
verifyHash(content)
|
||||
}
|
||||
if (newVerifiedHash != verifiedHash) {
|
||||
verifiedHash = newVerifiedHash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) }
|
||||
}
|
||||
|
||||
fun aspectRatio(dim: String?): Float? {
|
||||
if (dim == null) return null
|
||||
if (dim == "0x0") return null
|
||||
|
|
|
@ -0,0 +1,307 @@
|
|||
/**
|
||||
* 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.markdown
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.halilibo.richtext.ui.MediaRenderer
|
||||
import com.halilibo.richtext.ui.string.InlineContent
|
||||
import com.halilibo.richtext.ui.string.RichTextString
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.HashtagIcon
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayFullNote
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayUser
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size17Modifier
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class MarkdownMediaRenderer(
|
||||
val startOfText: String,
|
||||
val tags: ImmutableListOfLists<String>?,
|
||||
val canPreview: Boolean,
|
||||
val quotesLeft: Int,
|
||||
val backgroundColor: MutableState<Color>,
|
||||
val accountViewModel: AccountViewModel,
|
||||
val nav: (String) -> Unit,
|
||||
) : MediaRenderer {
|
||||
val parser = RichTextParser()
|
||||
|
||||
override fun shouldRenderLinkPreview(
|
||||
title: String?,
|
||||
uri: String,
|
||||
): Boolean {
|
||||
return if (canPreview && uri.startsWith("http")) {
|
||||
if (title.isNullOrBlank() || title == uri) {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun renderImage(
|
||||
title: String?,
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
if (canPreview) {
|
||||
val content =
|
||||
parser.parseMediaUrl(
|
||||
fullUrl = uri,
|
||||
eventTags = tags ?: EmptyTagList,
|
||||
description = title?.ifEmpty { null } ?: startOfText,
|
||||
) ?: MediaUrlImage(url = uri, description = title?.ifEmpty { null } ?: startOfText)
|
||||
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun renderLinkPreview(
|
||||
title: String?,
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText)
|
||||
|
||||
if (canPreview) {
|
||||
if (content != null) {
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (!accountViewModel.settings.showUrlPreview.value) {
|
||||
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
|
||||
} else {
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
LoadUrlPreview(uri, title ?: uri, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun renderNostrUri(
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
// This should be fast, so it is ok.
|
||||
val loadedLink =
|
||||
accountViewModel.bechLinkCache.cached(uri)
|
||||
?: runBlocking {
|
||||
accountViewModel.bechLinkCache.update(uri)
|
||||
}
|
||||
|
||||
val baseNote = loadedLink?.baseNote
|
||||
|
||||
if (canPreview && quotesLeft > 0 && baseNote != null) {
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
Row {
|
||||
DisplayFullNote(
|
||||
note = baseNote,
|
||||
extraChars = loadedLink.nip19.additionalChars?.ifBlank { null },
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (loadedLink?.nip19 != null) {
|
||||
when (val entity = loadedLink.nip19.entity) {
|
||||
is Nip19Bech32.NPub -> renderObservableUser(entity.hex, richTextStringBuilder)
|
||||
is Nip19Bech32.NProfile -> renderObservableUser(entity.hex, richTextStringBuilder)
|
||||
is Nip19Bech32.Note -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NEvent -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NEmbed -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NAddress -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NRelay -> renderShortNostrURI(uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NSec -> renderShortNostrURI(uri, richTextStringBuilder)
|
||||
else -> renderShortNostrURI(uri, richTextStringBuilder)
|
||||
}
|
||||
} else {
|
||||
renderShortNostrURI(uri, richTextStringBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun renderHashtag(
|
||||
tag: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val tagWithoutHash = tag.removePrefix("#")
|
||||
renderAsCompleteLink(tag, "nostr:Hashtag?id=$tagWithoutHash}", richTextStringBuilder)
|
||||
|
||||
val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(tagWithoutHash)
|
||||
if (hashtagIcon != null) {
|
||||
renderInline(richTextStringBuilder) {
|
||||
Box(Size17Modifier) {
|
||||
Icon(
|
||||
imageVector = hashtagIcon.icon,
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = Color.Unspecified,
|
||||
modifier = hashtagIcon.modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun renderObservableUser(
|
||||
userHex: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
renderInline(richTextStringBuilder) {
|
||||
DisplayUser(userHex, null, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
fun renderObservableShortNoteUri(
|
||||
loadedLink: LoadedBechLink,
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
loadedLink.baseNote?.let { renderNoteObserver(it, richTextStringBuilder) }
|
||||
renderShortNostrURI(uri, richTextStringBuilder)
|
||||
}
|
||||
|
||||
private fun renderNoteObserver(
|
||||
baseNote: Note,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
renderInvisible(richTextStringBuilder) {
|
||||
// Preloads note if not loaded yet.
|
||||
baseNote.live().metadata.observeAsState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderShortNostrURI(
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val nip19 = "@" + uri.removePrefix("nostr:")
|
||||
|
||||
renderAsCompleteLink(
|
||||
title =
|
||||
if (nip19.length > 16) {
|
||||
nip19.replaceRange(8, nip19.length - 8, ":")
|
||||
} else {
|
||||
nip19
|
||||
},
|
||||
destination = uri,
|
||||
richTextStringBuilder = richTextStringBuilder,
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderInvisible(
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
innerComposable: @Composable () -> Unit,
|
||||
) {
|
||||
richTextStringBuilder.appendInlineContent(
|
||||
content =
|
||||
InlineContent(
|
||||
initialSize = {
|
||||
IntSize(0.dp.roundToPx(), 0.dp.roundToPx())
|
||||
},
|
||||
) {
|
||||
innerComposable()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderInline(
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
innerComposable: @Composable () -> Unit,
|
||||
) {
|
||||
richTextStringBuilder.appendInlineContent(
|
||||
content =
|
||||
InlineContent(
|
||||
initialSize = {
|
||||
IntSize(Font17SP.roundToPx(), Font17SP.roundToPx())
|
||||
},
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
) {
|
||||
innerComposable()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderInlineFullWidth(
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
innerComposable: @Composable () -> Unit,
|
||||
) {
|
||||
richTextStringBuilder.appendInlineContentFullWidth(
|
||||
content =
|
||||
InlineContent(
|
||||
initialSize = {
|
||||
IntSize(Font17SP.roundToPx(), Font17SP.roundToPx())
|
||||
},
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
) {
|
||||
innerComposable()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderAsCompleteLink(
|
||||
title: String,
|
||||
destination: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
richTextStringBuilder.pushFormat(
|
||||
RichTextString.Format.Link(destination = destination),
|
||||
)
|
||||
richTextStringBuilder.append(title)
|
||||
richTextStringBuilder.pop()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* 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.markdown
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.commonmark.MarkdownParseOptions
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
@Composable
|
||||
fun RenderContentAsMarkdown(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val onClick =
|
||||
remember {
|
||||
{ link: String ->
|
||||
val route = uriToRoute(link)
|
||||
if (route != null) {
|
||||
nav(route)
|
||||
} else {
|
||||
runCatching { uri.openUri(link) }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
ProvideTextStyle(MarkdownTextStyle) {
|
||||
val astNode =
|
||||
remember(content) {
|
||||
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content)
|
||||
}
|
||||
|
||||
val renderer =
|
||||
remember(content) {
|
||||
MarkdownMediaRenderer(
|
||||
content.take(100),
|
||||
tags,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
RichText(
|
||||
style = MaterialTheme.colorScheme.markdownStyle,
|
||||
linkClickHandler = onClick,
|
||||
renderer = renderer,
|
||||
) {
|
||||
BasicMarkdown(astNode)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -45,7 +45,6 @@ class BookmarkPrivateFeedFilter(val account: Account) : FeedFilter<Note>() {
|
|||
return notes
|
||||
.plus(addresses)
|
||||
.toSet()
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,7 +40,6 @@ class BookmarkPublicFeedFilter(val account: Account) : FeedFilter<Note>() {
|
|||
return notes
|
||||
.plus(addresses)
|
||||
.toSet()
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,10 +31,11 @@ class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFe
|
|||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
return channel.notes.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
return sort(
|
||||
channel.notes.filterIntoSet { key, it ->
|
||||
account.isAcceptable(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
@ -44,6 +45,6 @@ class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFe
|
|||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,6 +47,6 @@ class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) :
|
|||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,15 +56,12 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
|||
.selectedChatsFollowList()
|
||||
.mapNotNull { LocalCache.getChannelIfExists(it) }
|
||||
.mapNotNull { it ->
|
||||
it.notes.values
|
||||
.filter { account.isAcceptable(it) && it.event != null }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.lastOrNull()
|
||||
it.notes.filter { key, it -> account.isAcceptable(it) && it.event != null }
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
return (privateMessages + publicChannels)
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
return (privateMessages + publicChannels).sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
override fun updateListWith(
|
||||
|
@ -197,6 +194,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
|||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -138,6 +138,6 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
|
|||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,16 +24,22 @@ import com.vitorpamplona.amethyst.model.Account
|
|||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
|
||||
class CommunityFeedFilter(val note: AddressableNote, val account: Account) :
|
||||
AdditiveFeedFilter<Note>() {
|
||||
class CommunityFeedFilter(val note: AddressableNote, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + note.idHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
val myPubKey = account.userProfile().pubkeyHex
|
||||
val result =
|
||||
LocalCache.notes.mapFlattenIntoSet { _, it ->
|
||||
filterMap(it, myPubKey)
|
||||
}
|
||||
|
||||
return sort(result)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
@ -41,31 +47,36 @@ class CommunityFeedFilter(val note: AddressableNote, val account: Account) :
|
|||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val myUnapprovedPosts =
|
||||
collection
|
||||
.asSequence()
|
||||
.filter { it.event is CommunityPostApprovalEvent } // Only Approvals
|
||||
.filter {
|
||||
it.author?.pubkeyHex == account.userProfile().pubkeyHex
|
||||
} // made by the logged in user
|
||||
.filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // for this community
|
||||
.filter { it.isNewThread() } // check if it is a new thread
|
||||
.toSet()
|
||||
val myPubKey = account.userProfile().pubkeyHex
|
||||
|
||||
val approvedPosts =
|
||||
collection
|
||||
.asSequence()
|
||||
.filter { it.event is CommunityPostApprovalEvent } // Only Approvals
|
||||
.filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // Of the given community
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten() // get approved posts
|
||||
.filter { it.isNewThread() } // check if it is a new thread
|
||||
.toSet()
|
||||
return collection.mapNotNull {
|
||||
filterMap(it, myPubKey)
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
return myUnapprovedPosts + approvedPosts
|
||||
private fun filterMap(
|
||||
note: Note,
|
||||
myPubKey: HexKey,
|
||||
): List<Note>? {
|
||||
return if (
|
||||
// Only Approvals
|
||||
note.event is CommunityPostApprovalEvent &&
|
||||
// Of the given community
|
||||
note.event?.isTaggedAddressableNote(this.note.idHex) == true
|
||||
) {
|
||||
// if it is my post, bring on
|
||||
if (note.author?.pubkeyHex == myPubKey && note.isNewThread()) {
|
||||
listOf(note)
|
||||
} else {
|
||||
// brings the actual posts, not the approvals
|
||||
note.replyTo?.filter { it.isNewThread() }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* 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.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
val DefaultFeedOrder = compareBy<Note>({ it.createdAt() }, { it.idHex }).reversed()
|
|
@ -21,15 +21,13 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
|
@ -44,65 +42,81 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
|
||||
val allChannelNotes =
|
||||
LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
|
||||
LocalCache.channels.mapNotNullIntoSet { _, channel ->
|
||||
if (channel is PublicChatChannel) {
|
||||
val note = LocalCache.getNoteIfExists(channel.idHex)
|
||||
val noteEvent = note?.event
|
||||
|
||||
val notes = innerApplyFilter(allChannelNotes)
|
||||
if (noteEvent == null || params.match(noteEvent)) {
|
||||
note
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
return sort(allChannelNotes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams {
|
||||
return FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultDiscoveryFollowList.value,
|
||||
followLists = account.liveDiscoveryFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
val params = buildFilterParams(account)
|
||||
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val createEvents = collection.filter { it.event is ChannelCreateEvent }
|
||||
val anyOtherChannelEvent =
|
||||
collection
|
||||
.asSequence()
|
||||
.filter { it.event is IsInPublicChatChannel }
|
||||
.mapNotNull { (it.event as? IsInPublicChatChannel)?.channel() }
|
||||
.mapNotNull { LocalCache.checkGetOrCreateNote(it) }
|
||||
.toSet()
|
||||
|
||||
val activities =
|
||||
(createEvents + anyOtherChannelEvent)
|
||||
.asSequence()
|
||||
// .filter { it.event is ChannelCreateEvent } // Event heads might not be loaded yet.
|
||||
.filter {
|
||||
isGlobal ||
|
||||
it.author?.pubkeyHex in followingKeySet ||
|
||||
it.event?.isTaggedHashes(followingTagSet) == true ||
|
||||
it.event?.isTaggedGeoHashes(followingGeohashSet) == true
|
||||
return collection.mapNotNullTo(HashSet()) { note ->
|
||||
// note event here will never be null
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is ChannelCreateEvent && params.match(noteEvent)) {
|
||||
if ((LocalCache.getChannelIfExists(noteEvent.id)?.notes?.size() ?: 0) > 0) {
|
||||
note
|
||||
} else {
|
||||
null
|
||||
}
|
||||
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
|
||||
.filter { (it.createdAt() ?: 0) <= now }
|
||||
.toSet()
|
||||
|
||||
return activities
|
||||
} else if (noteEvent is IsInPublicChatChannel) {
|
||||
val channel = noteEvent.channel()?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
if (channel != null &&
|
||||
(channel.event == null || (channel.event is ChannelCreateEvent && params.match(channel.event)))
|
||||
) {
|
||||
if ((LocalCache.getChannelIfExists(channel.idHex)?.notes?.size() ?: 0) > 0) {
|
||||
channel
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
val followingKeySet =
|
||||
account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users
|
||||
|
||||
val counter = ParticipantListBuilder()
|
||||
val participantCounts =
|
||||
collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) }
|
||||
val lastNote =
|
||||
collection.associateWith { note ->
|
||||
LocalCache.getChannelIfExists(note.idHex)?.lastNoteCreatedAt ?: 0
|
||||
}
|
||||
|
||||
return collection
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ participantCounts[it] },
|
||||
{ lastNote[it] },
|
||||
{ it.createdAt() },
|
||||
{ it.idHex },
|
||||
),
|
||||
|
|
|
@ -21,15 +21,13 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
|
@ -44,9 +42,27 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val allNotes = LocalCache.addressables.values
|
||||
val filterParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultDiscoveryFollowList.value,
|
||||
followLists = account.liveDiscoveryFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
val notes = innerApplyFilter(allNotes)
|
||||
// Here we only need to look for CommunityDefinition Events
|
||||
val notes =
|
||||
LocalCache.addressables.mapNotNullIntoSet { key, note ->
|
||||
val noteEvent = note.event
|
||||
if (noteEvent == null && shouldInclude(ATag.parseAtagUnckecked(key), filterParams)) {
|
||||
// send unloaded communities to the screen
|
||||
note
|
||||
} else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) {
|
||||
note
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
@ -56,57 +72,54 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
|
|||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
// here, we need to look for CommunityDefinition in new collection AND new CommunityDefinition from Post Approvals
|
||||
val filterParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultDiscoveryFollowList.value,
|
||||
followLists = account.liveDiscoveryFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
return collection.mapNotNull { note ->
|
||||
// note event here will never be null
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) {
|
||||
listOf(note)
|
||||
} else if (noteEvent is CommunityPostApprovalEvent) {
|
||||
noteEvent.communities().mapNotNull {
|
||||
val definitionNote = LocalCache.getOrCreateAddressableNote(it)
|
||||
val definitionEvent = definitionNote.event
|
||||
|
||||
val createEvents = collection.filter { it.event is CommunityDefinitionEvent }
|
||||
val anyOtherCommunityEvent =
|
||||
collection
|
||||
.asSequence()
|
||||
.filter { it.event is CommunityPostApprovalEvent }
|
||||
.mapNotNull { (it.event as? CommunityPostApprovalEvent)?.communities() }
|
||||
.flatten()
|
||||
.map { LocalCache.getOrCreateAddressableNote(it) }
|
||||
.toSet()
|
||||
|
||||
val activities =
|
||||
(createEvents + anyOtherCommunityEvent)
|
||||
.asSequence()
|
||||
.filter { it.event is CommunityDefinitionEvent }
|
||||
.filter {
|
||||
isGlobal ||
|
||||
it.author?.pubkeyHex in followingKeySet ||
|
||||
it.event?.isTaggedHashes(followingTagSet) == true ||
|
||||
it.event?.isTaggedGeoHashes(followingGeohashSet) == true
|
||||
if (definitionEvent == null && shouldInclude(it, filterParams)) {
|
||||
definitionNote
|
||||
} else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent)) {
|
||||
definitionNote
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
|
||||
.filter { (it.createdAt() ?: 0) <= now }
|
||||
.toSet()
|
||||
|
||||
return activities
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
private fun shouldInclude(
|
||||
aTag: ATag?,
|
||||
params: FilterByListParams,
|
||||
) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag)
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
val followingKeySet =
|
||||
account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users
|
||||
|
||||
val counter = ParticipantListBuilder()
|
||||
val participantCounts =
|
||||
collection.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) }
|
||||
|
||||
val allParticipants =
|
||||
collection.associate { it to counter.countFollowsThatParticipateOn(it, null) }
|
||||
val lastNote =
|
||||
collection.associateWith { note ->
|
||||
note.boosts.maxOfOrNull { it.createdAt() ?: 0 } ?: 0
|
||||
}
|
||||
|
||||
return collection
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ participantCounts[it] },
|
||||
{ allParticipants[it] },
|
||||
{ lastNote[it] },
|
||||
{ it.createdAt() },
|
||||
{ it.idHex },
|
||||
),
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
|
@ -31,7 +30,6 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
|||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverLiveFeedFilter(
|
||||
val account: Account,
|
||||
|
@ -50,9 +48,8 @@ open class DiscoverLiveFeedFilter(
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val allChannelNotes =
|
||||
LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
|
||||
val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten()
|
||||
val allChannelNotes = LocalCache.channels.mapNotNull { _, channel -> LocalCache.getNoteIfExists(channel.idHex) }
|
||||
val allMessageNotes = LocalCache.channels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten()
|
||||
|
||||
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
|
||||
|
||||
|
@ -64,33 +61,15 @@ open class DiscoverLiveFeedFilter(
|
|||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
val filterParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultDiscoveryFollowList.value,
|
||||
followLists = account.liveDiscoveryFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val activities =
|
||||
collection
|
||||
.asSequence()
|
||||
.filter { it.event is LiveActivitiesEvent }
|
||||
.filter {
|
||||
isGlobal ||
|
||||
(it.event as LiveActivitiesEvent).participantsIntersect(followingKeySet) ||
|
||||
it.event?.isTaggedHashes(
|
||||
followingTagSet,
|
||||
) == true ||
|
||||
it.event?.isTaggedGeoHashes(
|
||||
followingGeohashSet,
|
||||
) == true
|
||||
}
|
||||
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
|
||||
.filter { (it.createdAt() ?: 0) <= now }
|
||||
.toSet()
|
||||
|
||||
return activities
|
||||
return collection.filterTo(HashSet()) { it.event is LiveActivitiesEvent && filterParams.match(it.event) }
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
|
|
|
@ -21,13 +21,11 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverMarketplaceFeedFilter(
|
||||
val account: Account,
|
||||
|
@ -46,10 +44,13 @@ open class DiscoverMarketplaceFeedFilter(
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val classifieds =
|
||||
LocalCache.addressables.filter { it.value.event is ClassifiedsEvent }.map { it.value }
|
||||
val params = buildFilterParams(account)
|
||||
|
||||
val notes = innerApplyFilter(classifieds)
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet { _, it ->
|
||||
val noteEvent = it.event
|
||||
noteEvent is ClassifiedsEvent && noteEvent.isWellFormed() && params.match(noteEvent)
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
@ -58,35 +59,22 @@ open class DiscoverMarketplaceFeedFilter(
|
|||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams {
|
||||
return FilterByListParams.create(
|
||||
account.userProfile().pubkeyHex,
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
account.liveDiscoveryFollowLists.value,
|
||||
account.flowHiddenUsers.value,
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
val params = buildFilterParams(account)
|
||||
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val activities =
|
||||
collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.event is ClassifiedsEvent &&
|
||||
it.event?.hasTagWithContent("image") == true &&
|
||||
it.event?.hasTagWithContent("price") == true &&
|
||||
it.event?.hasTagWithContent("title") == true
|
||||
}
|
||||
.filter {
|
||||
isGlobal ||
|
||||
it.author?.pubkeyHex in followingKeySet ||
|
||||
it.event?.isTaggedHashes(followingTagSet) == true ||
|
||||
it.event?.isTaggedGeoHashes(followingGeohashSet) == true
|
||||
}
|
||||
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
|
||||
.filter { (it.createdAt() ?: 0) <= now }
|
||||
.toSet()
|
||||
|
||||
return activities
|
||||
return collection.filterTo(HashSet()) {
|
||||
val noteEvent = it.event
|
||||
noteEvent is ClassifiedsEvent && noteEvent.isWellFormed() && params.match(noteEvent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
|
|
|
@ -21,35 +21,36 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
|
||||
class DiscoverLiveNowFeedFilter(
|
||||
account: Account,
|
||||
) : DiscoverLiveFeedFilter(account) {
|
||||
override fun followList(): String {
|
||||
// uses follows by default, but other lists if they were selected in the top bar
|
||||
val currentList = super.followList()
|
||||
return if (currentList == GLOBAL_FOLLOWS) {
|
||||
KIND3_FOLLOWS
|
||||
} else {
|
||||
currentList
|
||||
class DraftEventsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptableEvent(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val allItems = super.innerApplyFilter(collection)
|
||||
|
||||
val onlineOnly =
|
||||
allItems.filter {
|
||||
val noteEvent = it.event as? LiveActivitiesEvent
|
||||
noteEvent?.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming())
|
||||
override fun feed(): List<Note> {
|
||||
val drafts =
|
||||
LocalCache.addressables.filterIntoSet { _, note ->
|
||||
acceptableEvent(note)
|
||||
}
|
||||
|
||||
return onlineOnly.toSet()
|
||||
return sort(drafts)
|
||||
}
|
||||
|
||||
fun acceptableEvent(it: Note): Boolean {
|
||||
val noteEvent = it.event
|
||||
return noteEvent is DraftEvent && noteEvent.pubKey == account.userProfile().pubkeyHex
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class FilterByListParams(
|
||||
val isGlobal: Boolean,
|
||||
val isHiddenList: Boolean,
|
||||
val followLists: Account.LiveFollowLists?,
|
||||
val hiddenLists: Account.LiveHiddenUsers,
|
||||
val now: Long = TimeUtils.oneMinuteFromNow(),
|
||||
) {
|
||||
fun isNotHidden(userHex: String) = !(hiddenLists.hiddenUsers.contains(userHex) || hiddenLists.spammers.contains(userHex))
|
||||
|
||||
fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now
|
||||
|
||||
fun isEventInList(noteEvent: Event): Boolean {
|
||||
if (followLists == null) return false
|
||||
|
||||
return if (noteEvent is LiveActivitiesEvent) {
|
||||
noteEvent.participantsIntersect(followLists.users) ||
|
||||
noteEvent.isTaggedHashes(followLists.hashtags) ||
|
||||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
|
||||
noteEvent.isTaggedAddressableNotes(followLists.communities)
|
||||
} else {
|
||||
noteEvent.pubKey in followLists.users ||
|
||||
noteEvent.isTaggedHashes(followLists.hashtags) ||
|
||||
noteEvent.isTaggedGeoHashes(followLists.geotags) ||
|
||||
noteEvent.isTaggedAddressableNotes(followLists.communities)
|
||||
}
|
||||
}
|
||||
|
||||
fun isATagInList(aTag: ATag): Boolean {
|
||||
if (followLists == null) return false
|
||||
|
||||
return aTag.pubKeyHex in followLists.users
|
||||
}
|
||||
|
||||
fun match(
|
||||
noteEvent: EventInterface?,
|
||||
isGlobalRelay: Boolean = true,
|
||||
) = if (noteEvent is Event) match(noteEvent, isGlobalRelay) else false
|
||||
|
||||
fun match(
|
||||
noteEvent: Event,
|
||||
isGlobalRelay: Boolean = true,
|
||||
) = ((isGlobal && isGlobalRelay) || isEventInList(noteEvent)) &&
|
||||
(isHiddenList || isNotHidden(noteEvent.pubKey)) &&
|
||||
isNotInTheFuture(noteEvent)
|
||||
|
||||
fun match(aTag: ATag?) =
|
||||
aTag != null &&
|
||||
(isGlobal || isATagInList(aTag)) &&
|
||||
(isHiddenList || isNotHidden(aTag.pubKeyHex))
|
||||
|
||||
companion object {
|
||||
fun showHiddenKey(
|
||||
selectedListName: String,
|
||||
userHex: String,
|
||||
) = selectedListName == PeopleListEvent.blockListFor(userHex) || selectedListName == MuteListEvent.blockListFor(userHex)
|
||||
|
||||
fun create(
|
||||
userHex: String,
|
||||
selectedListName: String,
|
||||
followLists: Account.LiveFollowLists?,
|
||||
hiddenUsers: Account.LiveHiddenUsers,
|
||||
): FilterByListParams {
|
||||
return FilterByListParams(
|
||||
isGlobal = selectedListName == GLOBAL_FOLLOWS,
|
||||
isHiddenList = showHiddenKey(selectedListName, userHex),
|
||||
followLists = followLists,
|
||||
hiddenLists = hiddenUsers,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -36,7 +36,12 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it, tag)
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
@ -44,25 +49,24 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
|||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val myTag = tag ?: return emptySet()
|
||||
return collection.filterTo(HashSet<Note>()) { acceptableEvent(it, tag) }
|
||||
}
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) && it.event?.isTaggedGeoHash(myTag) == true
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.toSet()
|
||||
fun acceptableEvent(
|
||||
it: Note,
|
||||
geoTag: String,
|
||||
): Boolean {
|
||||
return (
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) && it.event?.isTaggedGeoHash(geoTag) == true && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,12 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it, tag)
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
@ -44,25 +49,24 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
|||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val myTag = tag ?: return emptySet()
|
||||
return collection.filterTo(HashSet<Note>()) { acceptableEvent(it, tag) }
|
||||
}
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) && it.event?.isTaggedHash(myTag) == true
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.toSet()
|
||||
fun acceptableEvent(
|
||||
it: Note,
|
||||
hashTag: String,
|
||||
): Boolean {
|
||||
return (
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) && it.event?.isTaggedHash(hashTag) == true && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
|
@ -30,7 +29,6 @@ import com.vitorpamplona.quartz.events.MuteListEvent
|
|||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
|
@ -38,55 +36,54 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
|||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultHomeFollowList.value ==
|
||||
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultHomeFollowList.value ==
|
||||
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
val filterParams = buildFilterParams(account)
|
||||
|
||||
return sort(
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it, filterParams)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams {
|
||||
return FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultHomeFollowList.value,
|
||||
followLists = account.liveHomeFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
val filterParams = buildFilterParams(account)
|
||||
|
||||
val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet()
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptableEvent(it, filterParams)
|
||||
}
|
||||
}
|
||||
|
||||
val now = TimeUtils.now()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is LiveActivitiesChatMessageEvent
|
||||
) &&
|
||||
(
|
||||
isGlobal ||
|
||||
it.author?.pubkeyHex in followingKeySet ||
|
||||
it.event?.isTaggedHashes(followingTagSet) ?: false ||
|
||||
it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false
|
||||
) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if
|
||||
// acceptable
|
||||
(isHiddenList || it.author?.let { !account.isHidden(it) } ?: true) &&
|
||||
((it.event?.createdAt() ?: 0) < now) &&
|
||||
!it.isNewThread()
|
||||
}
|
||||
.toSet()
|
||||
fun acceptableEvent(
|
||||
it: Note,
|
||||
filterParams: FilterByListParams,
|
||||
): Boolean {
|
||||
return (
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is LiveActivitiesChatMessageEvent
|
||||
) && filterParams.match(it.event) && !it.isNewThread()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
|
@ -35,7 +34,6 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
|
|||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
|
@ -43,69 +41,68 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
|||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultHomeFollowList.value ==
|
||||
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultHomeFollowList.value ==
|
||||
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams {
|
||||
return FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultHomeFollowList.value,
|
||||
followLists = account.liveHomeFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.noteListCache, true)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false)
|
||||
val gRelays = account.activeGlobalRelays().toSet()
|
||||
val filterParams = buildFilterParams(account)
|
||||
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
// Avoids processing addressables twice.
|
||||
(note.event?.kind() ?: 99999) < 10000 && acceptableEvent(note, gRelays, filterParams)
|
||||
}
|
||||
|
||||
val longFormNotes =
|
||||
LocalCache.addressables.filterIntoSet { _, note ->
|
||||
acceptableEvent(note, gRelays, filterParams)
|
||||
}
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection, false)
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(
|
||||
collection: Collection<Note>,
|
||||
ignoreAddressables: Boolean,
|
||||
): Set<Note> {
|
||||
val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS
|
||||
val gRelays = account.activeGlobalRelays()
|
||||
val isHiddenList = showHiddenKey()
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val gRelays = account.activeGlobalRelays().toSet()
|
||||
val filterParams = buildFilterParams(account)
|
||||
|
||||
val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet()
|
||||
val followingCommunities = account.liveHomeFollowLists.value?.communities ?: emptySet()
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptableEvent(it, gRelays, filterParams)
|
||||
}
|
||||
}
|
||||
|
||||
val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future.
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
val noteEvent = it.event
|
||||
val isGlobalRelay = it.relays.any { gRelays.contains(it.url) }
|
||||
(
|
||||
noteEvent is TextNoteEvent ||
|
||||
noteEvent is ClassifiedsEvent ||
|
||||
noteEvent is RepostEvent ||
|
||||
noteEvent is GenericRepostEvent ||
|
||||
noteEvent is LongTextNoteEvent ||
|
||||
noteEvent is PollNoteEvent ||
|
||||
noteEvent is HighlightEvent ||
|
||||
noteEvent is AudioTrackEvent ||
|
||||
noteEvent is AudioHeaderEvent
|
||||
) &&
|
||||
(!ignoreAddressables || noteEvent.kind() < 10000) &&
|
||||
(
|
||||
(isGlobal && isGlobalRelay) ||
|
||||
it.author?.pubkeyHex in followingKeySet ||
|
||||
noteEvent.isTaggedHashes(followingTagSet) ||
|
||||
noteEvent.isTaggedGeoHashes(followingGeohashSet) ||
|
||||
noteEvent.isTaggedAddressableNotes(followingCommunities)
|
||||
) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if
|
||||
// acceptable
|
||||
(isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) &&
|
||||
((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) &&
|
||||
it.isNewThread()
|
||||
}
|
||||
.toSet()
|
||||
private fun acceptableEvent(
|
||||
it: Note,
|
||||
globalRelays: Set<String>,
|
||||
filterParams: FilterByListParams,
|
||||
): Boolean {
|
||||
val noteEvent = it.event
|
||||
val isGlobalRelay = it.relays.any { globalRelays.contains(it.url) }
|
||||
return (
|
||||
noteEvent is TextNoteEvent ||
|
||||
noteEvent is ClassifiedsEvent ||
|
||||
noteEvent is RepostEvent ||
|
||||
noteEvent is GenericRepostEvent ||
|
||||
noteEvent is LongTextNoteEvent ||
|
||||
noteEvent is PollNoteEvent ||
|
||||
noteEvent is HighlightEvent ||
|
||||
noteEvent is AudioTrackEvent ||
|
||||
noteEvent is AudioHeaderEvent
|
||||
) && filterParams.match(noteEvent, isGlobalRelay) && it.isNewThread()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
|
@ -115,6 +112,6 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
|||
} else {
|
||||
it.idHex
|
||||
}
|
||||
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
|
@ -54,8 +53,24 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
|||
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams {
|
||||
return FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultNotificationFollowList.value,
|
||||
followLists = account.liveNotificationFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
val filterParams = buildFilterParams(account)
|
||||
|
||||
val notifications =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
acceptableEvent(note, filterParams)
|
||||
}
|
||||
|
||||
return sort(notifications)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
@ -63,32 +78,49 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
|||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val isGlobal = account.defaultNotificationFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
val filterParams = buildFilterParams(account)
|
||||
|
||||
val followingKeySet = account.liveNotificationFollowLists.value?.users ?: emptySet()
|
||||
return collection.filterTo(HashSet()) { acceptableEvent(it, filterParams) }
|
||||
}
|
||||
|
||||
val loggedInUser = account.userProfile()
|
||||
val loggedInUserHex = loggedInUser.pubkeyHex
|
||||
fun acceptableEvent(
|
||||
it: Note,
|
||||
filterParams: FilterByListParams,
|
||||
): Boolean {
|
||||
val loggedInUserHex = account.userProfile().pubkeyHex
|
||||
|
||||
return collection
|
||||
.filterTo(HashSet()) {
|
||||
it.event !is ChannelCreateEvent &&
|
||||
it.event !is ChannelMetadataEvent &&
|
||||
it.event !is LnZapRequestEvent &&
|
||||
it.event !is BadgeDefinitionEvent &&
|
||||
it.event !is BadgeProfilesEvent &&
|
||||
it.event !is GiftWrapEvent &&
|
||||
(it.event is LnZapEvent || it.author !== loggedInUser) &&
|
||||
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
|
||||
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
(isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
|
||||
tagsAnEventByUser(it, loggedInUserHex)
|
||||
val noteEvent = it.event
|
||||
val notifAuthor =
|
||||
if (noteEvent is LnZapEvent) {
|
||||
val zapRequest = noteEvent.zapRequest
|
||||
if (zapRequest != null) {
|
||||
if (noteEvent.zapRequest?.isPrivateZap() == true) {
|
||||
zapRequest.cachedPrivateZap()?.pubKey ?: zapRequest.pubKey
|
||||
} else {
|
||||
zapRequest.pubKey
|
||||
}
|
||||
} else {
|
||||
noteEvent.pubKey
|
||||
}
|
||||
} else {
|
||||
it.author?.pubkeyHex
|
||||
}
|
||||
|
||||
return it.event !is ChannelCreateEvent &&
|
||||
it.event !is ChannelMetadataEvent &&
|
||||
it.event !is LnZapRequestEvent &&
|
||||
it.event !is BadgeDefinitionEvent &&
|
||||
it.event !is BadgeProfilesEvent &&
|
||||
it.event !is GiftWrapEvent &&
|
||||
(it.event is LnZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) &&
|
||||
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
|
||||
tagsAnEventByUser(it, loggedInUserHex)
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
fun tagsAnEventByUser(
|
||||
|
|
|
@ -31,7 +31,12 @@ class UserProfileAppRecommendationsFeedFilter(val user: User) : AdditiveFeedFilt
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.addressables.values))
|
||||
val recommendations =
|
||||
LocalCache.addressables.mapFlattenIntoSet { _, note ->
|
||||
filterMap(note)
|
||||
}
|
||||
|
||||
return sort(recommendations)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
@ -39,26 +44,21 @@ class UserProfileAppRecommendationsFeedFilter(val user: User) : AdditiveFeedFilt
|
|||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val recommendations =
|
||||
collection
|
||||
.asSequence()
|
||||
.filter { it.event is AppRecommendationEvent }
|
||||
.mapNotNull {
|
||||
val noteEvent = it.event as? AppRecommendationEvent
|
||||
if (noteEvent != null && noteEvent.pubKey == user.pubkeyHex) {
|
||||
noteEvent.recommendations()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.flatten()
|
||||
.map { LocalCache.getOrCreateAddressableNote(it) }
|
||||
.toSet()
|
||||
return collection.mapNotNull { filterMap(it) }.flatten().toSet()
|
||||
}
|
||||
|
||||
return recommendations
|
||||
fun filterMap(it: Note): List<Note>? {
|
||||
val noteEvent = it.event
|
||||
if (noteEvent is AppRecommendationEvent) {
|
||||
if (noteEvent.pubKey == user.pubkeyHex) {
|
||||
return noteEvent.recommendations().map { LocalCache.getOrCreateAddressableNote(it) }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,6 @@ class UserProfileBookmarksFeedFilter(val user: User, val account: Account) : Fee
|
|||
|
||||
return (notes + addresses)
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,17 @@ class UserProfileConversationsFeedFilter(val user: User, val account: Account) :
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.noteListCache))
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it)
|
||||
}
|
||||
|
||||
val longFormNotes =
|
||||
LocalCache.addressables.filterIntoSet { _, it ->
|
||||
acceptableEvent(it)
|
||||
}
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
|
@ -44,23 +54,21 @@ class UserProfileConversationsFeedFilter(val user: User, val account: Account) :
|
|||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
return collection
|
||||
.filter {
|
||||
it.author == user &&
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is LiveActivitiesChatMessageEvent
|
||||
) &&
|
||||
!it.isNewThread() &&
|
||||
account.isAcceptable(it) == true
|
||||
}
|
||||
.toSet()
|
||||
return collection.filterTo(HashSet()) { acceptableEvent(it) }
|
||||
}
|
||||
|
||||
fun acceptableEvent(it: Note): Boolean {
|
||||
return it.author == user &&
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is LiveActivitiesChatMessageEvent
|
||||
) && !it.isNewThread() && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
override fun limit() = 200
|
||||
|
|
|
@ -30,7 +30,9 @@ class UserProfileFollowersFeedFilter(val user: User, val account: Account) : Fee
|
|||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return LocalCache.userListCache.filter { it.isFollowing(user) && !account.isHidden(it) }
|
||||
return LocalCache.users.filter { _, it ->
|
||||
it.isFollowing(user) && !account.isHidden(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun limit() = 400
|
||||
|
|
|
@ -21,9 +21,11 @@
|
|||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
|
@ -41,8 +43,15 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) :
|
|||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.noteListCache)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
it !is AddressableNote && it.event !is AddressableEvent && acceptableEvent(it)
|
||||
}
|
||||
|
||||
val longFormNotes =
|
||||
LocalCache.addressables.filterIntoSet { _, it ->
|
||||
acceptableEvent(it)
|
||||
}
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
}
|
||||
|
@ -52,28 +61,26 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) :
|
|||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
return collection
|
||||
.filter {
|
||||
it.author == user &&
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is ClassifiedsEvent ||
|
||||
it.event is RepostEvent ||
|
||||
it.event is GenericRepostEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is HighlightEvent ||
|
||||
it.event is AudioTrackEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) &&
|
||||
it.isNewThread() &&
|
||||
account.isAcceptable(it) == true
|
||||
}
|
||||
.toSet()
|
||||
return collection.filterTo(HashSet()) { acceptableEvent(it) }
|
||||
}
|
||||
|
||||
fun acceptableEvent(it: Note): Boolean {
|
||||
return it.author == user &&
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is ClassifiedsEvent ||
|
||||
it.event is RepostEvent ||
|
||||
it.event is GenericRepostEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is HighlightEvent ||
|
||||
it.event is AudioTrackEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
) && it.isNewThread() && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return collection.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
override fun limit() = 200
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue