mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-03-28 18:51:45 +01:00
Adds support for Blossom media servers.
This commit is contained in:
parent
c89c5eb4b0
commit
2c9e2de524
@ -26,12 +26,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||||
|
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||||
import com.vitorpamplona.amethyst.service.FileHeader
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
|
||||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
|
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import junit.framework.TestCase.fail
|
import junit.framework.TestCase.fail
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -47,7 +52,69 @@ import kotlin.random.Random
|
|||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ImageUploadTesting {
|
class ImageUploadTesting {
|
||||||
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
|
val account =
|
||||||
|
Account(
|
||||||
|
AccountSettings(KeyPair()),
|
||||||
|
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun getBitmap(): ByteArray {
|
||||||
|
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
|
||||||
|
for (x in 0 until bitmap.width) {
|
||||||
|
for (y in 0 until bitmap.height) {
|
||||||
|
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val baos = ByteArrayOutputStream()
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
|
||||||
|
return baos.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun testBase(server: ServerName) {
|
||||||
|
if (server.type == ServerType.NIP96) {
|
||||||
|
testNip96(server)
|
||||||
|
} else {
|
||||||
|
testBlossom(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun testBlossom(server: ServerName) {
|
||||||
|
val paylod = getBitmap()
|
||||||
|
val initialHash = CryptoUtils.sha256(paylod).toHexKey()
|
||||||
|
val inputStream = paylod.inputStream()
|
||||||
|
val result =
|
||||||
|
BlossomUploader(account)
|
||||||
|
.uploadImage(
|
||||||
|
inputStream,
|
||||||
|
initialHash,
|
||||||
|
paylod.size,
|
||||||
|
"filename.png",
|
||||||
|
"image/png",
|
||||||
|
alt = null,
|
||||||
|
sensitiveContent = null,
|
||||||
|
server,
|
||||||
|
forceProxy = { false },
|
||||||
|
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("image/png", result.type)
|
||||||
|
assertEquals(paylod.size.toLong(), result.size)
|
||||||
|
assertEquals(initialHash, result.sha256)
|
||||||
|
assertEquals("${server.baseUrl}/$initialHash", result.url)
|
||||||
|
|
||||||
|
val imageData: ByteArray =
|
||||||
|
ImageDownloader().waitAndGetImage(result.url!!, false)
|
||||||
|
?: run {
|
||||||
|
fail("${server.name}: Should not be null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadedHash = CryptoUtils.sha256(imageData).toHexKey()
|
||||||
|
assertEquals(initialHash, downloadedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun testNip96(server: ServerName) {
|
||||||
val serverInfo =
|
val serverInfo =
|
||||||
Nip96Retriever()
|
Nip96Retriever()
|
||||||
.loadInfo(
|
.loadInfo(
|
||||||
@ -55,28 +122,13 @@ class ImageUploadTesting {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
|
val paylod = getBitmap()
|
||||||
for (x in 0 until bitmap.width) {
|
val inputStream = paylod.inputStream()
|
||||||
for (y in 0 until bitmap.height) {
|
|
||||||
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val baos = ByteArrayOutputStream()
|
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
|
|
||||||
val bytes = baos.toByteArray()
|
|
||||||
val inputStream = bytes.inputStream()
|
|
||||||
|
|
||||||
val account =
|
|
||||||
Account(
|
|
||||||
AccountSettings(KeyPair()),
|
|
||||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result =
|
val result =
|
||||||
Nip96Uploader(account)
|
Nip96Uploader(account)
|
||||||
.uploadImage(
|
.uploadImage(
|
||||||
inputStream,
|
inputStream,
|
||||||
bytes.size.toLong(),
|
paylod.size.toLong(),
|
||||||
"image/png",
|
"image/png",
|
||||||
alt = null,
|
alt = null,
|
||||||
sensitiveContent = null,
|
sensitiveContent = null,
|
||||||
@ -140,7 +192,7 @@ class ImageUploadTesting {
|
|||||||
@Test
|
@Test
|
||||||
fun runTestOnDefaultServers() =
|
fun runTestOnDefaultServers() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
Nip96MediaServers.DEFAULT.forEach {
|
DEFAULT_MEDIA_SERVERS.forEach {
|
||||||
testBase(it)
|
testBase(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,58 +200,76 @@ class ImageUploadTesting {
|
|||||||
@Test()
|
@Test()
|
||||||
fun testNostrCheck() =
|
fun testNostrCheck() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
|
testBase(ServerName("nostrcheck.me", "https://nostrcheck.me", ServerType.NIP96))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test()
|
@Test()
|
||||||
@Ignore("Not Working anymore")
|
@Ignore("Not Working anymore")
|
||||||
fun testNostrage() =
|
fun testNostrage() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
|
testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test()
|
@Test()
|
||||||
@Ignore("Not Working anymore")
|
@Ignore("Not Working anymore")
|
||||||
fun testSove() =
|
fun testSove() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
|
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test()
|
@Test()
|
||||||
fun testNostrBuild() =
|
fun testNostrBuild() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
|
testBase(ServerName("nostr.build", "https://nostr.build", ServerType.NIP96))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test()
|
@Test()
|
||||||
@Ignore("Not Working anymore")
|
@Ignore("Not Working anymore")
|
||||||
fun testSovbit() =
|
fun testSovbit() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
|
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test()
|
@Test()
|
||||||
fun testVoidCat() =
|
fun testVoidCat() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
|
testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test()
|
@Test()
|
||||||
fun testNostrPic() =
|
fun testNostrPic() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
|
testBase(ServerName("nostpic.com", "https://nostpic.com", ServerType.NIP96))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(expected = RuntimeException::class)
|
@Test(expected = RuntimeException::class)
|
||||||
fun testSprovoostNl() =
|
fun testSprovoostNl() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/"))
|
testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test()
|
@Test()
|
||||||
@Ignore("Not Working anymore")
|
@Ignore("Not Working anymore")
|
||||||
fun testNostrOnch() =
|
fun testNostrOnch() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services"))
|
testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Ignore("Changes sha256")
|
||||||
|
fun testPrimalBlossom() =
|
||||||
|
runBlocking {
|
||||||
|
testBase(ServerName("primal.net", "https://blossom.primal.net", ServerType.Blossom))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test()
|
||||||
|
fun testNostrCheckBlossom() =
|
||||||
|
runBlocking {
|
||||||
|
testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Ignore("Requires Payment")
|
||||||
|
fun testSatelliteBlossom() =
|
||||||
|
runBlocking {
|
||||||
|
testBase(ServerName("satellite", "https://cdn.satellite.earth", ServerType.Blossom))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,8 +37,9 @@ import com.vitorpamplona.amethyst.model.DefaultZapAmounts
|
|||||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||||
import com.vitorpamplona.amethyst.model.Settings
|
import com.vitorpamplona.amethyst.model.Settings
|
||||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
|
||||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||||
@ -521,7 +522,7 @@ object LocalPreferences {
|
|||||||
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
|
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
|
||||||
|
|
||||||
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||||
val defaultFileServer = parseOrNull<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0]
|
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
|
||||||
|
|
||||||
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
||||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||||
|
@ -33,6 +33,7 @@ import coil3.util.DebugLogger
|
|||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.service.Base64Fetcher
|
import com.vitorpamplona.amethyst.service.Base64Fetcher
|
||||||
|
import com.vitorpamplona.amethyst.service.BlurHashFetcher
|
||||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||||
@ -134,6 +135,9 @@ class ServiceManager(
|
|||||||
}
|
}
|
||||||
add(SvgDecoder.Factory())
|
add(SvgDecoder.Factory())
|
||||||
add(Base64Fetcher.Factory)
|
add(Base64Fetcher.Factory)
|
||||||
|
add(BlurHashFetcher.Factory)
|
||||||
|
add(Base64Fetcher.BKeyer)
|
||||||
|
add(BlurHashFetcher.BKeyer)
|
||||||
add(
|
add(
|
||||||
OkHttpNetworkFetcherFactory(
|
OkHttpNetworkFetcherFactory(
|
||||||
callFactory = {
|
callFactory = {
|
||||||
|
@ -36,6 +36,9 @@ import com.vitorpamplona.amethyst.service.LocationState
|
|||||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||||
import com.vitorpamplona.amethyst.tryAndWait
|
import com.vitorpamplona.amethyst.tryAndWait
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||||
import com.vitorpamplona.ammolite.relays.Client
|
import com.vitorpamplona.ammolite.relays.Client
|
||||||
import com.vitorpamplona.ammolite.relays.Constants
|
import com.vitorpamplona.ammolite.relays.Constants
|
||||||
@ -57,6 +60,8 @@ import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
|||||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||||
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
|
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
|
||||||
|
import com.vitorpamplona.quartz.events.BlossomAuthorizationEvent
|
||||||
|
import com.vitorpamplona.quartz.events.BlossomServersEvent
|
||||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||||
@ -138,6 +143,7 @@ import kotlinx.coroutines.flow.transformLatest
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.czeal.rfc3986.URIReference
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@ -628,6 +634,19 @@ class Account(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val liveServerList: StateFlow<List<ServerName>> by lazy {
|
||||||
|
combine(getFileServersListFlow(), getBlossomServersListFlow()) { nip96, blossom ->
|
||||||
|
mergeServerList(nip96.note.event as? FileServersEvent, blossom.note.event as? BlossomServersEvent)
|
||||||
|
}.flowOn(Dispatchers.Default)
|
||||||
|
.stateIn(
|
||||||
|
scope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
runBlocking {
|
||||||
|
mergeServerList(getFileServersList(), getBlossomServersList())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun loadAndCombineFlows(listName: String): LiveFollowList? {
|
suspend fun loadAndCombineFlows(listName: String): LiveFollowList? {
|
||||||
val flows = loadFlowsFor(listName)
|
val flows = loadFlowsFor(listName)
|
||||||
return mapIntoFollowLists(
|
return mapIntoFollowLists(
|
||||||
@ -1482,6 +1501,26 @@ class Account(
|
|||||||
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
|
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createBlossomUploadAuth(
|
||||||
|
hash: HexKey,
|
||||||
|
alt: String,
|
||||||
|
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
BlossomAuthorizationEvent.createUploadAuth(hash, alt, signer, onReady = onReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createBlossomDeleteAuth(
|
||||||
|
hash: HexKey,
|
||||||
|
alt: String,
|
||||||
|
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, onReady = onReady)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun boost(note: Note) {
|
suspend fun boost(note: Note) {
|
||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
|
|
||||||
@ -3663,6 +3702,31 @@ class Account(
|
|||||||
|
|
||||||
fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddressATag(userProfile().pubkeyHex))
|
fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddressATag(userProfile().pubkeyHex))
|
||||||
|
|
||||||
|
fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent
|
||||||
|
|
||||||
|
fun getBlossomServersListFlow(): StateFlow<NoteState> = getBlossomServersNote().flow().metadata.stateFlow
|
||||||
|
|
||||||
|
fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(BlossomServersEvent.createAddressATag(userProfile().pubkeyHex))
|
||||||
|
|
||||||
|
fun host(url: String): String =
|
||||||
|
try {
|
||||||
|
URIReference.parse(url).host.value
|
||||||
|
} catch (e: Exception) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mergeServerList(
|
||||||
|
nip96: FileServersEvent?,
|
||||||
|
blossom: BlossomServersEvent?,
|
||||||
|
): List<ServerName> {
|
||||||
|
val nip96servers = nip96?.servers()?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList()
|
||||||
|
val blossomServers = blossom?.servers()?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList()
|
||||||
|
|
||||||
|
val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS }
|
||||||
|
|
||||||
|
return result + ServerName("NIP95", "", ServerType.NIP95)
|
||||||
|
}
|
||||||
|
|
||||||
fun sendFileServersList(servers: List<String>) {
|
fun sendFileServersList(servers: List<String>) {
|
||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
|
|
||||||
@ -3688,6 +3752,31 @@ class Account(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendBlossomServersList(servers: List<String>) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
val serverList = getBlossomServersList()
|
||||||
|
|
||||||
|
if (serverList != null && serverList.tags.isNotEmpty()) {
|
||||||
|
BlossomServersEvent.updateRelayList(
|
||||||
|
earlierVersion = serverList,
|
||||||
|
relays = servers,
|
||||||
|
signer = signer,
|
||||||
|
) {
|
||||||
|
Client.send(it)
|
||||||
|
LocalCache.justConsume(it, null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BlossomServersEvent.createFromScratch(
|
||||||
|
relays = servers,
|
||||||
|
signer = signer,
|
||||||
|
) {
|
||||||
|
Client.send(it)
|
||||||
|
LocalCache.justConsume(it, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getAllPeopleLists(): List<AddressableNote> = getAllPeopleLists(signer.pubKey)
|
fun getAllPeopleLists(): List<AddressableNote> = getAllPeopleLists(signer.pubKey)
|
||||||
|
|
||||||
fun getAllPeopleLists(pubkey: HexKey): List<AddressableNote> =
|
fun getAllPeopleLists(pubkey: HexKey): List<AddressableNote> =
|
||||||
|
@ -23,7 +23,8 @@ package com.vitorpamplona.amethyst.model
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||||
import com.vitorpamplona.ammolite.relays.Constants
|
import com.vitorpamplona.ammolite.relays.Constants
|
||||||
@ -98,7 +99,7 @@ class AccountSettings(
|
|||||||
var externalSignerPackageName: String? = null,
|
var externalSignerPackageName: String? = null,
|
||||||
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
|
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
|
||||||
var localRelayServers: Set<String> = setOf(),
|
var localRelayServers: Set<String> = setOf(),
|
||||||
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
|
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
|
||||||
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
|
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
|
||||||
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||||
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||||
@ -202,7 +203,7 @@ class AccountSettings(
|
|||||||
// file servers
|
// file servers
|
||||||
// ---
|
// ---
|
||||||
|
|
||||||
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
|
fun changeDefaultFileServer(server: ServerName) {
|
||||||
if (defaultFileServer != server) {
|
if (defaultFileServer != server) {
|
||||||
defaultFileServer = server
|
defaultFileServer = server
|
||||||
saveAccountSettings()
|
saveAccountSettings()
|
||||||
|
@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
|
|||||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||||
import com.vitorpamplona.quartz.events.BaseAddressableEvent
|
import com.vitorpamplona.quartz.events.BaseAddressableEvent
|
||||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.events.BlossomServersEvent
|
||||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||||
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
||||||
import com.vitorpamplona.quartz.events.CalendarEvent
|
import com.vitorpamplona.quartz.events.CalendarEvent
|
||||||
@ -713,6 +714,13 @@ object LocalCache {
|
|||||||
consumeBaseReplaceable(event, relay)
|
consumeBaseReplaceable(event, relay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun consume(
|
||||||
|
event: BlossomServersEvent,
|
||||||
|
relay: Relay?,
|
||||||
|
) {
|
||||||
|
consumeBaseReplaceable(event, relay)
|
||||||
|
}
|
||||||
|
|
||||||
fun consume(
|
fun consume(
|
||||||
event: FileServersEvent,
|
event: FileServersEvent,
|
||||||
relay: Relay?,
|
relay: Relay?,
|
||||||
@ -2342,6 +2350,7 @@ object LocalCache {
|
|||||||
is BadgeAwardEvent -> consume(event, relay)
|
is BadgeAwardEvent -> consume(event, relay)
|
||||||
is BadgeDefinitionEvent -> consume(event, relay)
|
is BadgeDefinitionEvent -> consume(event, relay)
|
||||||
is BadgeProfilesEvent -> consume(event)
|
is BadgeProfilesEvent -> consume(event)
|
||||||
|
is BlossomServersEvent -> consume(event, relay)
|
||||||
is BookmarkListEvent -> consume(event)
|
is BookmarkListEvent -> consume(event)
|
||||||
is CalendarEvent -> consume(event, relay)
|
is CalendarEvent -> consume(event, relay)
|
||||||
is CalendarDateSlotEvent -> consume(event, relay)
|
is CalendarDateSlotEvent -> consume(event, relay)
|
||||||
|
@ -22,17 +22,20 @@ package com.vitorpamplona.amethyst.service
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
|
import coil3.Uri
|
||||||
import coil3.asImage
|
import coil3.asImage
|
||||||
import coil3.decode.DataSource
|
import coil3.decode.DataSource
|
||||||
import coil3.fetch.FetchResult
|
import coil3.fetch.FetchResult
|
||||||
import coil3.fetch.Fetcher
|
import coil3.fetch.Fetcher
|
||||||
import coil3.fetch.ImageFetchResult
|
import coil3.fetch.ImageFetchResult
|
||||||
|
import coil3.key.Keyer
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
|
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
|
||||||
|
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||||
|
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
@ -66,13 +69,24 @@ class Base64Fetcher(
|
|||||||
data: Uri,
|
data: Uri,
|
||||||
options: Options,
|
options: Options,
|
||||||
imageLoader: ImageLoader,
|
imageLoader: ImageLoader,
|
||||||
): Fetcher? {
|
): Fetcher? =
|
||||||
return if (base64contentPattern.matcher(data.toString()).find()) {
|
if (data.scheme == "data") {
|
||||||
return Base64Fetcher(options, data)
|
Base64Fetcher(options, data)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object BKeyer : Keyer<Uri> {
|
||||||
|
override fun key(
|
||||||
|
data: Uri,
|
||||||
|
options: Options,
|
||||||
|
): String? =
|
||||||
|
if (data.scheme == "data") {
|
||||||
|
CryptoUtils.sha256(data.toString().toByteArray()).toHexKey()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2024 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.vitorpamplona.amethyst.BuildConfig
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.tryAndWait
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||||
|
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||||
|
import com.vitorpamplona.quartz.encoders.HexKey
|
||||||
|
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.events.Dimension
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okio.BufferedSink
|
||||||
|
import okio.source
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.Base64
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class BlossomUploader(
|
||||||
|
val account: Account?,
|
||||||
|
) {
|
||||||
|
fun Context.getFileName(uri: Uri): String? =
|
||||||
|
when (uri.scheme) {
|
||||||
|
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
|
||||||
|
else -> uri.path?.let(::File)?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Context.getContentFileName(uri: Uri): String? =
|
||||||
|
runCatching {
|
||||||
|
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
suspend fun uploadImage(
|
||||||
|
uri: Uri,
|
||||||
|
contentType: String?,
|
||||||
|
size: Long?,
|
||||||
|
alt: String?,
|
||||||
|
sensitiveContent: String?,
|
||||||
|
server: ServerName,
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
forceProxy: (String) -> Boolean,
|
||||||
|
context: Context,
|
||||||
|
): MediaUploadResult {
|
||||||
|
checkNotInMainThread()
|
||||||
|
|
||||||
|
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||||
|
val fileName = context.getFileName(uri)
|
||||||
|
|
||||||
|
val imageInputStreamForHash = contentResolver.openInputStream(uri)
|
||||||
|
val payload =
|
||||||
|
imageInputStreamForHash?.use {
|
||||||
|
it.readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
checkNotNull(payload) { "Can't open the image input stream" }
|
||||||
|
|
||||||
|
val hash = CryptoUtils.sha256(payload).toHexKey()
|
||||||
|
|
||||||
|
val imageInputStream = contentResolver.openInputStream(uri)
|
||||||
|
|
||||||
|
checkNotNull(imageInputStream) { "Can't open the image input stream" }
|
||||||
|
|
||||||
|
return uploadImage(
|
||||||
|
imageInputStream,
|
||||||
|
hash,
|
||||||
|
payload.size,
|
||||||
|
fileName,
|
||||||
|
myContentType,
|
||||||
|
alt,
|
||||||
|
sensitiveContent,
|
||||||
|
server,
|
||||||
|
forceProxy,
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadImage(
|
||||||
|
inputStream: InputStream,
|
||||||
|
hash: HexKey,
|
||||||
|
length: Int,
|
||||||
|
fileName: String?,
|
||||||
|
contentType: String?,
|
||||||
|
alt: String?,
|
||||||
|
sensitiveContent: String?,
|
||||||
|
server: ServerName,
|
||||||
|
forceProxy: (String) -> Boolean,
|
||||||
|
context: Context,
|
||||||
|
): MediaUploadResult {
|
||||||
|
checkNotInMainThread()
|
||||||
|
|
||||||
|
val fileName = randomChars()
|
||||||
|
val extension =
|
||||||
|
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||||
|
|
||||||
|
val apiUrl = server.baseUrl.removeSuffix("/") + "/upload"
|
||||||
|
|
||||||
|
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
|
||||||
|
val requestBuilder = Request.Builder()
|
||||||
|
|
||||||
|
val requestBody: RequestBody =
|
||||||
|
object : RequestBody() {
|
||||||
|
override fun contentType() = contentType?.toMediaType()
|
||||||
|
|
||||||
|
override fun contentLength() = length.toLong()
|
||||||
|
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
inputStream.source().use(sink::writeAll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authUploadHeader(
|
||||||
|
hash,
|
||||||
|
alt?.let { "Uploading $it" } ?: "Uploading $fileName",
|
||||||
|
)?.let {
|
||||||
|
requestBuilder.addHeader("Authorization", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType?.let { requestBuilder.addHeader("Content-Type", it) }
|
||||||
|
|
||||||
|
requestBuilder
|
||||||
|
.addHeader("Content-Length", length.toString())
|
||||||
|
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||||
|
.url(apiUrl)
|
||||||
|
.put(requestBody)
|
||||||
|
|
||||||
|
val request = requestBuilder.build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body.use { body ->
|
||||||
|
val str = body.string()
|
||||||
|
val result = parseResults(str)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.headers.get("X-Reason")
|
||||||
|
|
||||||
|
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||||
|
if (errorMessage != null) {
|
||||||
|
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
|
||||||
|
} else if (explanation != null) {
|
||||||
|
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
|
||||||
|
} else {
|
||||||
|
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(
|
||||||
|
hash: String,
|
||||||
|
contentType: String?,
|
||||||
|
server: ServerName,
|
||||||
|
forceProxy: (String) -> Boolean,
|
||||||
|
context: Context,
|
||||||
|
): Boolean {
|
||||||
|
val extension =
|
||||||
|
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||||
|
|
||||||
|
val apiUrl = server.baseUrl
|
||||||
|
|
||||||
|
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
|
||||||
|
|
||||||
|
val requestBuilder = Request.Builder()
|
||||||
|
|
||||||
|
authDeleteHeader(
|
||||||
|
hash,
|
||||||
|
"Deleting $hash",
|
||||||
|
)?.let { requestBuilder.addHeader("Authorization", it) }
|
||||||
|
|
||||||
|
val request =
|
||||||
|
requestBuilder
|
||||||
|
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||||
|
.url(apiUrl.removeSuffix("/") + "/$hash.$extension")
|
||||||
|
.delete()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||||
|
if (explanation != null) {
|
||||||
|
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
|
||||||
|
} else {
|
||||||
|
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun authUploadHeader(
|
||||||
|
hash: String,
|
||||||
|
alt: String,
|
||||||
|
): String? {
|
||||||
|
val myAccount = account ?: return null
|
||||||
|
return tryAndWait { continuation ->
|
||||||
|
myAccount.createBlossomUploadAuth(hash, alt) {
|
||||||
|
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||||
|
continuation.resume("Nostr $encodedNIP98Event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun authDeleteHeader(
|
||||||
|
hash: String,
|
||||||
|
alt: String,
|
||||||
|
): String? {
|
||||||
|
val myAccount = account ?: return null
|
||||||
|
return tryAndWait { continuation ->
|
||||||
|
myAccount.createBlossomDeleteAuth(hash, alt) {
|
||||||
|
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||||
|
continuation.resume("Nostr $encodedNIP98Event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseResults(body: String): MediaUploadResult {
|
||||||
|
val mapper =
|
||||||
|
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
return mapper.readValue(body, MediaUploadResult::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MediaUploadResult(
|
||||||
|
// A publicly accessible URL to the BUD-01 GET /<sha256> endpoint (optionally with a file extension)
|
||||||
|
val url: String?,
|
||||||
|
// The sha256 hash of the blob
|
||||||
|
val sha256: HexKey? = null,
|
||||||
|
// The size of the blob in bytes
|
||||||
|
val size: Long? = null,
|
||||||
|
// (optional) The MIME type of the blob
|
||||||
|
val type: String? = null,
|
||||||
|
// upload time
|
||||||
|
val uploaded: Long? = null,
|
||||||
|
// dimensions
|
||||||
|
val dimension: Dimension? = null,
|
||||||
|
// magnet link
|
||||||
|
val magnet: String? = null,
|
||||||
|
val infohash: String? = null,
|
||||||
|
// ipfs link
|
||||||
|
val ipfs: String? = null,
|
||||||
|
)
|
@ -20,8 +20,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.asImage
|
import coil3.asImage
|
||||||
@ -29,23 +27,25 @@ import coil3.decode.DataSource
|
|||||||
import coil3.fetch.FetchResult
|
import coil3.fetch.FetchResult
|
||||||
import coil3.fetch.Fetcher
|
import coil3.fetch.Fetcher
|
||||||
import coil3.fetch.ImageFetchResult
|
import coil3.fetch.ImageFetchResult
|
||||||
import coil3.request.ImageRequest
|
import coil3.key.Keyer
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
|
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
|
||||||
import java.net.URLDecoder
|
|
||||||
import java.net.URLEncoder
|
class Blurhash(
|
||||||
|
val blurhash: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class BlurHashFetcher(
|
class BlurHashFetcher(
|
||||||
private val options: Options,
|
private val options: Options,
|
||||||
private val data: Uri,
|
private val data: Blurhash,
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
override suspend fun fetch(): FetchResult {
|
override suspend fun fetch(): FetchResult {
|
||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
|
|
||||||
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8")
|
val hash = data.blurhash
|
||||||
|
|
||||||
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
|
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data")
|
||||||
|
|
||||||
return ImageFetchResult(
|
return ImageFetchResult(
|
||||||
image = bitmap.asImage(true),
|
image = bitmap.asImage(true),
|
||||||
@ -54,26 +54,18 @@ class BlurHashFetcher(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
object Factory : Fetcher.Factory<Uri> {
|
object Factory : Fetcher.Factory<Blurhash> {
|
||||||
override fun create(
|
override fun create(
|
||||||
data: Uri,
|
data: Blurhash,
|
||||||
options: Options,
|
options: Options,
|
||||||
imageLoader: ImageLoader,
|
imageLoader: ImageLoader,
|
||||||
): Fetcher = BlurHashFetcher(options, data)
|
): Fetcher = BlurHashFetcher(options, data)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
object BlurHashRequester {
|
object BKeyer : Keyer<Blurhash> {
|
||||||
fun imageRequest(
|
override fun key(
|
||||||
context: Context,
|
data: Blurhash,
|
||||||
message: String,
|
options: Options,
|
||||||
): ImageRequest {
|
): String = data.blurhash
|
||||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
|
||||||
|
|
||||||
return ImageRequest
|
|
||||||
.Builder(context)
|
|
||||||
.data("bluehash:$encodedMessage")
|
|
||||||
.fetcherFactory(BlurHashFetcher.Factory)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,20 +31,6 @@ import java.net.URI
|
|||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
||||||
object Nip96MediaServers {
|
object Nip96MediaServers {
|
||||||
val DEFAULT =
|
|
||||||
listOf(
|
|
||||||
ServerName("Nostr.Build", "https://nostr.build"),
|
|
||||||
ServerName("NostrCheck.me", "https://nostrcheck.me"),
|
|
||||||
ServerName("NostPic", "https://nostpic.com"),
|
|
||||||
ServerName("Sovbit", "https://files.sovbit.host"),
|
|
||||||
ServerName("Void.cat", "https://void.cat"),
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ServerName(
|
|
||||||
val name: String,
|
|
||||||
val baseUrl: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
||||||
|
|
||||||
suspend fun load(
|
suspend fun load(
|
||||||
@ -136,23 +122,23 @@ class Nip96Retriever {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun makeAbsoluteIfRelativeUrl(
|
||||||
|
baseUrl: String,
|
||||||
|
potentialyRelativeUrl: String,
|
||||||
|
): String =
|
||||||
|
try {
|
||||||
|
val apiUrl = URI(potentialyRelativeUrl)
|
||||||
|
if (apiUrl.isAbsolute) {
|
||||||
|
potentialyRelativeUrl
|
||||||
|
} else {
|
||||||
|
URL(URL(baseUrl), potentialyRelativeUrl).toString()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
potentialyRelativeUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typealias PlanName = String
|
typealias PlanName = String
|
||||||
|
|
||||||
typealias MimeType = String
|
typealias MimeType = String
|
||||||
|
|
||||||
fun makeAbsoluteIfRelativeUrl(
|
|
||||||
baseUrl: String,
|
|
||||||
potentialyRelativeUrl: String,
|
|
||||||
): String =
|
|
||||||
try {
|
|
||||||
val apiUrl = URI(potentialyRelativeUrl)
|
|
||||||
if (apiUrl.isAbsolute) {
|
|
||||||
potentialyRelativeUrl
|
|
||||||
} else {
|
|
||||||
URL(URL(baseUrl), potentialyRelativeUrl).toString()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
potentialyRelativeUrl
|
|
||||||
}
|
|
||||||
|
@ -33,8 +33,10 @@ import com.vitorpamplona.amethyst.BuildConfig
|
|||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.tryAndWait
|
import com.vitorpamplona.amethyst.tryAndWait
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||||
|
import com.vitorpamplona.quartz.events.Dimension
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
@ -59,12 +61,12 @@ class Nip96Uploader(
|
|||||||
size: Long?,
|
size: Long?,
|
||||||
alt: String?,
|
alt: String?,
|
||||||
sensitiveContent: String?,
|
sensitiveContent: String?,
|
||||||
server: Nip96MediaServers.ServerName,
|
server: ServerName,
|
||||||
contentResolver: ContentResolver,
|
contentResolver: ContentResolver,
|
||||||
forceProxy: (String) -> Boolean,
|
forceProxy: (String) -> Boolean,
|
||||||
onProgress: (percentage: Float) -> Unit,
|
onProgress: (percentage: Float) -> Unit,
|
||||||
context: Context,
|
context: Context,
|
||||||
): PartialEvent {
|
): MediaUploadResult {
|
||||||
val serverInfo =
|
val serverInfo =
|
||||||
Nip96Retriever()
|
Nip96Retriever()
|
||||||
.loadInfo(
|
.loadInfo(
|
||||||
@ -97,7 +99,7 @@ class Nip96Uploader(
|
|||||||
forceProxy: (String) -> Boolean,
|
forceProxy: (String) -> Boolean,
|
||||||
onProgress: (percentage: Float) -> Unit,
|
onProgress: (percentage: Float) -> Unit,
|
||||||
context: Context,
|
context: Context,
|
||||||
): PartialEvent {
|
): MediaUploadResult {
|
||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
|
|
||||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||||
@ -137,7 +139,7 @@ class Nip96Uploader(
|
|||||||
forceProxy: (String) -> Boolean,
|
forceProxy: (String) -> Boolean,
|
||||||
onProgress: (percentage: Float) -> Unit,
|
onProgress: (percentage: Float) -> Unit,
|
||||||
context: Context,
|
context: Context,
|
||||||
): PartialEvent {
|
): MediaUploadResult {
|
||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
|
|
||||||
val fileName = randomChars()
|
val fileName = randomChars()
|
||||||
@ -189,7 +191,7 @@ class Nip96Uploader(
|
|||||||
if (!result.processingUrl.isNullOrBlank()) {
|
if (!result.processingUrl.isNullOrBlank()) {
|
||||||
return waitProcessing(result, server, forceProxy, onProgress)
|
return waitProcessing(result, server, forceProxy, onProgress)
|
||||||
} else if (result.status == "success" && result.nip94Event != null) {
|
} else if (result.status == "success" && result.nip94Event != null) {
|
||||||
return result.nip94Event
|
return convertToMediaResult(result.nip94Event)
|
||||||
} else {
|
} else {
|
||||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
|
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
|
||||||
}
|
}
|
||||||
@ -223,6 +225,40 @@ class Nip96Uploader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult {
|
||||||
|
// Images don't seem to be ready immediately after upload
|
||||||
|
val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||||
|
val remoteMimeType =
|
||||||
|
nip96.tags
|
||||||
|
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
||||||
|
?.get(1)
|
||||||
|
?.ifBlank { null }
|
||||||
|
val originalHash =
|
||||||
|
nip96.tags
|
||||||
|
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
||||||
|
?.get(1)
|
||||||
|
?.ifBlank { null }
|
||||||
|
val dim =
|
||||||
|
nip96.tags
|
||||||
|
?.firstOrNull { it.size > 1 && it[0] == "dim" }
|
||||||
|
?.get(1)
|
||||||
|
?.ifBlank { null }
|
||||||
|
?.let { Dimension.parse(it) }
|
||||||
|
val magnet =
|
||||||
|
nip96.tags
|
||||||
|
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
||||||
|
?.get(1)
|
||||||
|
?.ifBlank { null }
|
||||||
|
|
||||||
|
return MediaUploadResult(
|
||||||
|
url = imageUrl,
|
||||||
|
type = remoteMimeType,
|
||||||
|
sha256 = originalHash,
|
||||||
|
dimension = dim,
|
||||||
|
magnet = magnet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun delete(
|
suspend fun delete(
|
||||||
hash: String,
|
hash: String,
|
||||||
contentType: String?,
|
contentType: String?,
|
||||||
@ -269,7 +305,7 @@ class Nip96Uploader(
|
|||||||
server: Nip96Retriever.ServerInfo,
|
server: Nip96Retriever.ServerInfo,
|
||||||
forceProxy: (String) -> Boolean,
|
forceProxy: (String) -> Boolean,
|
||||||
onProgress: (percentage: Float) -> Unit,
|
onProgress: (percentage: Float) -> Unit,
|
||||||
): PartialEvent {
|
): MediaUploadResult {
|
||||||
var currentResult = result
|
var currentResult = result
|
||||||
|
|
||||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||||
@ -296,7 +332,7 @@ class Nip96Uploader(
|
|||||||
val nip94 = currentResult.nip94Event
|
val nip94 = currentResult.nip94Event
|
||||||
|
|
||||||
if (nip94 != null) {
|
if (nip94 != null) {
|
||||||
return nip94
|
return convertToMediaResult(nip94)
|
||||||
} else {
|
} else {
|
||||||
throw RuntimeException("Error waiting for processing. Final result is unavailable")
|
throw RuntimeException("Error waiting for processing. Final result is unavailable")
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
|||||||
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
|
import com.vitorpamplona.quartz.events.AppSpecificDataEvent
|
||||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||||
|
import com.vitorpamplona.quartz.events.BlossomServersEvent
|
||||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||||
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
||||||
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
|
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
|
||||||
@ -94,10 +95,11 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
|||||||
ChatMessageRelayListEvent.KIND,
|
ChatMessageRelayListEvent.KIND,
|
||||||
SearchRelayListEvent.KIND,
|
SearchRelayListEvent.KIND,
|
||||||
FileServersEvent.KIND,
|
FileServersEvent.KIND,
|
||||||
|
BlossomServersEvent.KIND,
|
||||||
PrivateOutboxRelayListEvent.KIND,
|
PrivateOutboxRelayListEvent.KIND,
|
||||||
),
|
),
|
||||||
authors = listOf(account.userProfile().pubkeyHex),
|
authors = listOf(account.userProfile().pubkeyHex),
|
||||||
limit = 10,
|
limit = 20,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -116,11 +118,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
|||||||
ChatMessageRelayListEvent.KIND,
|
ChatMessageRelayListEvent.KIND,
|
||||||
SearchRelayListEvent.KIND,
|
SearchRelayListEvent.KIND,
|
||||||
FileServersEvent.KIND,
|
FileServersEvent.KIND,
|
||||||
|
BlossomServersEvent.KIND,
|
||||||
MuteListEvent.KIND,
|
MuteListEvent.KIND,
|
||||||
PeopleListEvent.KIND,
|
PeopleListEvent.KIND,
|
||||||
),
|
),
|
||||||
authors = otherAuthors,
|
authors = otherAuthors,
|
||||||
limit = otherAuthors.size * 10,
|
limit = otherAuthors.size * 20,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.R
|
|||||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||||
@ -336,8 +337,8 @@ fun EditPostView(
|
|||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||||
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||||
if (!server.isNip95) {
|
if (server.type != ServerType.NIP95) {
|
||||||
accountViewModel.account.settings.changeDefaultFileServer(server.server)
|
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancel = { postViewModel.contentToAddUrl = null },
|
onCancel = { postViewModel.contentToAddUrl = null },
|
||||||
|
@ -38,14 +38,17 @@ import com.vitorpamplona.amethyst.model.Account
|
|||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||||
import com.vitorpamplona.amethyst.service.FileHeader
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
|
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||||
import com.vitorpamplona.quartz.events.Dimension
|
|
||||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||||
@ -151,7 +154,7 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
sensitiveContent: Boolean,
|
sensitiveContent: Boolean,
|
||||||
mediaQuality: Int,
|
mediaQuality: Int,
|
||||||
isPrivate: Boolean = false,
|
isPrivate: Boolean = false,
|
||||||
server: ServerOption,
|
server: ServerName,
|
||||||
onError: (String, String) -> Unit,
|
onError: (String, String) -> Unit,
|
||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
@ -168,7 +171,7 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
contentType,
|
contentType,
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
onReady = { fileUri, contentType, size ->
|
onReady = { fileUri, contentType, size ->
|
||||||
if (server.isNip95) {
|
if (server.type == ServerType.NIP95) {
|
||||||
contentResolver.openInputStream(fileUri)?.use {
|
contentResolver.openInputStream(fileUri)?.use {
|
||||||
createNIP95Record(
|
createNIP95Record(
|
||||||
it.readBytes(),
|
it.readBytes(),
|
||||||
@ -181,7 +184,7 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else if (server.type == ServerType.NIP96) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val result =
|
val result =
|
||||||
@ -192,13 +195,52 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
size = size,
|
size = size,
|
||||||
alt = alt,
|
alt = alt,
|
||||||
sensitiveContent = if (sensitiveContent) "" else null,
|
sensitiveContent = if (sensitiveContent) "" else null,
|
||||||
server = server.server,
|
server = server,
|
||||||
contentResolver = contentResolver,
|
contentResolver = contentResolver,
|
||||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
onProgress = {},
|
onProgress = {},
|
||||||
context = context,
|
context = context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createNIP94Record(
|
||||||
|
uploadingResult = result,
|
||||||
|
localContentType = contentType,
|
||||||
|
alt = alt,
|
||||||
|
sensitiveContent = sensitiveContent,
|
||||||
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
|
onError = {
|
||||||
|
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||||
|
},
|
||||||
|
context = context,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.e(
|
||||||
|
"ImageUploader",
|
||||||
|
"Failed to upload ${e.message}",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
isUploadingImage = false
|
||||||
|
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
BlossomUploader(account)
|
||||||
|
.uploadImage(
|
||||||
|
uri = fileUri,
|
||||||
|
contentType = contentType,
|
||||||
|
size = size,
|
||||||
|
alt = alt,
|
||||||
|
sensitiveContent = if (sensitiveContent) "" else null,
|
||||||
|
server = server,
|
||||||
|
contentResolver = contentResolver,
|
||||||
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
|
context = context,
|
||||||
|
)
|
||||||
|
|
||||||
createNIP94Record(
|
createNIP94Record(
|
||||||
uploadingResult = result,
|
uploadingResult = result,
|
||||||
localContentType = contentType,
|
localContentType = contentType,
|
||||||
@ -317,7 +359,7 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
contentToAddUrl == null
|
contentToAddUrl == null
|
||||||
|
|
||||||
suspend fun createNIP94Record(
|
suspend fun createNIP94Record(
|
||||||
uploadingResult: Nip96Uploader.PartialEvent,
|
uploadingResult: MediaUploadResult,
|
||||||
localContentType: String?,
|
localContentType: String?,
|
||||||
alt: String?,
|
alt: String?,
|
||||||
sensitiveContent: Boolean,
|
sensitiveContent: Boolean,
|
||||||
@ -325,31 +367,7 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
onError: (String) -> Unit = {},
|
onError: (String) -> Unit = {},
|
||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
// Images don't seem to be ready immediately after upload
|
if (uploadingResult.url.isNullOrBlank()) {
|
||||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
|
||||||
val remoteMimeType =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
val originalHash =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
val dim =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "dim" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
?.let { Dimension.parse(it) }
|
|
||||||
val magnet =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
|
|
||||||
if (imageUrl.isNullOrBlank()) {
|
|
||||||
Log.e("ImageDownload", "Couldn't download image from server")
|
Log.e("ImageDownload", "Couldn't download image from server")
|
||||||
cancel()
|
cancel()
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
@ -358,16 +376,16 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FileHeader.prepare(
|
FileHeader.prepare(
|
||||||
fileUrl = imageUrl,
|
fileUrl = uploadingResult.url,
|
||||||
mimeType = remoteMimeType ?: localContentType,
|
mimeType = uploadingResult.type ?: localContentType,
|
||||||
dimPrecomputed = dim,
|
dimPrecomputed = uploadingResult.dimension,
|
||||||
forceProxy = forceProxy(imageUrl),
|
forceProxy = forceProxy(uploadingResult.url),
|
||||||
onReady = { header: FileHeader ->
|
onReady = { header: FileHeader ->
|
||||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
nip94attachments = nip94attachments + event
|
nip94attachments = nip94attachments + event
|
||||||
|
|
||||||
message = message.insertUrlAtCursor(imageUrl)
|
message = message.insertUrlAtCursor(uploadingResult.url)
|
||||||
urlPreview = findUrlInMessage()
|
urlPreview = findUrlInMessage()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -31,22 +31,20 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||||
import com.vitorpamplona.amethyst.service.FileHeader
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||||
import com.vitorpamplona.quartz.events.Dimension
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class ServerOption(
|
|
||||||
val server: Nip96MediaServers.ServerName,
|
|
||||||
val isNip95: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
open class NewMediaModel : ViewModel() {
|
open class NewMediaModel : ViewModel() {
|
||||||
var account: Account? = null
|
var account: Account? = null
|
||||||
@ -54,7 +52,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
var isUploadingImage by mutableStateOf(false)
|
var isUploadingImage by mutableStateOf(false)
|
||||||
var mediaType by mutableStateOf<String?>(null)
|
var mediaType by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
var selectedServer by mutableStateOf<ServerOption?>(null)
|
var selectedServer by mutableStateOf<ServerName?>(null)
|
||||||
var alt by mutableStateOf("")
|
var alt by mutableStateOf("")
|
||||||
var sensitiveContent by mutableStateOf(false)
|
var sensitiveContent by mutableStateOf(false)
|
||||||
|
|
||||||
@ -74,7 +72,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
this.account = account
|
this.account = account
|
||||||
this.galleryUri = uri
|
this.galleryUri = uri
|
||||||
this.mediaType = contentType
|
this.mediaType = contentType
|
||||||
this.selectedServer = ServerOption(defaultServer(), false)
|
this.selectedServer = defaultServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun upload(
|
fun upload(
|
||||||
@ -100,7 +98,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
contentType,
|
contentType,
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
onReady = { fileUri, contentType, size ->
|
onReady = { fileUri, contentType, size ->
|
||||||
if (serverToUse.isNip95) {
|
if (serverToUse.type == ServerType.NIP95) {
|
||||||
uploadingPercentage.value = 0.2f
|
uploadingPercentage.value = 0.2f
|
||||||
uploadingDescription.value = "Loading"
|
uploadingDescription.value = "Loading"
|
||||||
contentResolver.openInputStream(fileUri)?.use {
|
contentResolver.openInputStream(fileUri)?.use {
|
||||||
@ -122,7 +120,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
uploadingDescription.value = null
|
uploadingDescription.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (serverToUse.type == ServerType.NIP96) {
|
||||||
uploadingPercentage.value = 0.2f
|
uploadingPercentage.value = 0.2f
|
||||||
uploadingDescription.value = "Uploading"
|
uploadingDescription.value = "Uploading"
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
@ -135,7 +133,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
size = size,
|
size = size,
|
||||||
alt = alt,
|
alt = alt,
|
||||||
sensitiveContent = if (sensitiveContent) "" else null,
|
sensitiveContent = if (sensitiveContent) "" else null,
|
||||||
server = serverToUse.server,
|
server = serverToUse,
|
||||||
contentResolver = contentResolver,
|
contentResolver = contentResolver,
|
||||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
onProgress = { percent: Float ->
|
onProgress = { percent: Float ->
|
||||||
@ -144,6 +142,43 @@ open class NewMediaModel : ViewModel() {
|
|||||||
context = context,
|
context = context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createNIP94Record(
|
||||||
|
uploadingResult = result,
|
||||||
|
localContentType = contentType,
|
||||||
|
alt = alt,
|
||||||
|
sensitiveContent = sensitiveContent,
|
||||||
|
relayList = relayList,
|
||||||
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
|
onError = onError,
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
isUploadingImage = false
|
||||||
|
uploadingPercentage.value = 0.00f
|
||||||
|
uploadingDescription.value = null
|
||||||
|
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (serverToUse.type == ServerType.Blossom) {
|
||||||
|
uploadingPercentage.value = 0.2f
|
||||||
|
uploadingDescription.value = "Uploading"
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
BlossomUploader(account)
|
||||||
|
.uploadImage(
|
||||||
|
uri = fileUri,
|
||||||
|
contentType = contentType,
|
||||||
|
size = size,
|
||||||
|
alt = alt,
|
||||||
|
sensitiveContent = if (sensitiveContent) "" else null,
|
||||||
|
server = serverToUse,
|
||||||
|
contentResolver = contentResolver,
|
||||||
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
|
context = context,
|
||||||
|
)
|
||||||
|
|
||||||
createNIP94Record(
|
createNIP94Record(
|
||||||
uploadingResult = result,
|
uploadingResult = result,
|
||||||
localContentType = contentType,
|
localContentType = contentType,
|
||||||
@ -183,13 +218,13 @@ open class NewMediaModel : ViewModel() {
|
|||||||
uploadingPercentage.value = 0.0f
|
uploadingPercentage.value = 0.0f
|
||||||
|
|
||||||
alt = ""
|
alt = ""
|
||||||
selectedServer = ServerOption(defaultServer(), false)
|
selectedServer = defaultServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null
|
fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null
|
||||||
|
|
||||||
suspend fun createNIP94Record(
|
suspend fun createNIP94Record(
|
||||||
uploadingResult: Nip96Uploader.PartialEvent,
|
uploadingResult: MediaUploadResult,
|
||||||
localContentType: String?,
|
localContentType: String?,
|
||||||
alt: String,
|
alt: String,
|
||||||
sensitiveContent: Boolean,
|
sensitiveContent: Boolean,
|
||||||
@ -202,30 +237,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
uploadingDescription.value = "Server Processing"
|
uploadingDescription.value = "Server Processing"
|
||||||
// Images don't seem to be ready immediately after upload
|
// Images don't seem to be ready immediately after upload
|
||||||
|
|
||||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
if (uploadingResult.url.isNullOrBlank()) {
|
||||||
val remoteMimeType =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
val originalHash =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
val dim =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "dim" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
?.let { Dimension.parse(it) }
|
|
||||||
val magnet =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
|
|
||||||
if (imageUrl.isNullOrBlank()) {
|
|
||||||
Log.e("ImageDownload", "Couldn't download image from server")
|
Log.e("ImageDownload", "Couldn't download image from server")
|
||||||
cancel()
|
cancel()
|
||||||
uploadingPercentage.value = 0.00f
|
uploadingPercentage.value = 0.00f
|
||||||
@ -238,7 +250,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
uploadingDescription.value = "Downloading"
|
uploadingDescription.value = "Downloading"
|
||||||
uploadingPercentage.value = 0.60f
|
uploadingPercentage.value = 0.60f
|
||||||
|
|
||||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl, forceProxy(imageUrl))
|
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadingResult.url, forceProxy(uploadingResult.url))
|
||||||
|
|
||||||
if (imageData != null) {
|
if (imageData != null) {
|
||||||
uploadingPercentage.value = 0.80f
|
uploadingPercentage.value = 0.80f
|
||||||
@ -246,18 +258,18 @@ open class NewMediaModel : ViewModel() {
|
|||||||
|
|
||||||
FileHeader.prepare(
|
FileHeader.prepare(
|
||||||
data = imageData,
|
data = imageData,
|
||||||
mimeType = remoteMimeType ?: localContentType,
|
mimeType = uploadingResult.type ?: localContentType,
|
||||||
dimPrecomputed = dim,
|
dimPrecomputed = uploadingResult.dimension,
|
||||||
onReady = {
|
onReady = {
|
||||||
uploadingPercentage.value = 0.90f
|
uploadingPercentage.value = 0.90f
|
||||||
uploadingDescription.value = "Sending"
|
uploadingDescription.value = "Sending"
|
||||||
account?.sendHeader(
|
account?.sendHeader(
|
||||||
imageUrl,
|
uploadingResult.url,
|
||||||
magnet,
|
uploadingResult.magnet,
|
||||||
it,
|
it,
|
||||||
alt,
|
alt,
|
||||||
sensitiveContent,
|
sensitiveContent,
|
||||||
originalHash,
|
uploadingResult.sha256,
|
||||||
relayList,
|
relayList,
|
||||||
) {
|
) {
|
||||||
uploadingPercentage.value = 1.00f
|
uploadingPercentage.value = 1.00f
|
||||||
@ -340,7 +352,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
|
|
||||||
fun isVideo() = mediaType?.startsWith("video")
|
fun isVideo() = mediaType?.startsWith("video")
|
||||||
|
|
||||||
fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
|
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||||
|
|
||||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||||
this.onceUploaded = onceUploaded
|
this.onceUploaded = onceUploaded
|
||||||
|
@ -55,8 +55,8 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -75,7 +75,7 @@ import androidx.compose.ui.window.Dialog
|
|||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||||
@ -89,7 +89,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -163,8 +162,8 @@ fun NewMediaView(
|
|||||||
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||||
}
|
}
|
||||||
postViewModel.selectedServer?.let {
|
postViewModel.selectedServer?.let {
|
||||||
if (!it.isNip95) {
|
if (it.type != ServerType.NIP95) {
|
||||||
account.settings.changeDefaultFileServer(it.server)
|
account.settings.changeDefaultFileServer(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -282,39 +281,21 @@ fun ImageVideoPost(
|
|||||||
postViewModel: NewMediaModel,
|
postViewModel: NewMediaModel,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
val listOfNip96ServersNote =
|
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
|
||||||
accountViewModel.account
|
val fileServers by accountViewModel.account.liveServerList.collectAsState()
|
||||||
.getFileServersNote()
|
|
||||||
.live()
|
|
||||||
.metadata
|
|
||||||
.observeAsState()
|
|
||||||
|
|
||||||
val fileServers =
|
|
||||||
(
|
|
||||||
(listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map {
|
|
||||||
ServerOption(
|
|
||||||
Nip96MediaServers.ServerName(
|
|
||||||
it,
|
|
||||||
it,
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
} ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) }
|
|
||||||
) +
|
|
||||||
listOf(
|
|
||||||
ServerOption(
|
|
||||||
Nip96MediaServers.ServerName(
|
|
||||||
"NIP95",
|
|
||||||
stringRes(id = R.string.upload_server_relays_nip95),
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val fileServerOptions =
|
val fileServerOptions =
|
||||||
remember {
|
remember(fileServers) {
|
||||||
fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList()
|
fileServers
|
||||||
|
.map {
|
||||||
|
if (it.type == ServerType.NIP95) {
|
||||||
|
TitleExplainer(it.name, nip95description)
|
||||||
|
} else {
|
||||||
|
TitleExplainer(it.name, it.baseUrl)
|
||||||
|
}
|
||||||
|
}.toImmutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val resolver = LocalContext.current.contentResolver
|
val resolver = LocalContext.current.contentResolver
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@ -381,10 +362,9 @@ fun ImageVideoPost(
|
|||||||
label = stringRes(id = R.string.file_server),
|
label = stringRes(id = R.string.file_server),
|
||||||
placeholder =
|
placeholder =
|
||||||
fileServers
|
fileServers
|
||||||
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
|
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
|
||||||
?.server
|
|
||||||
?.name
|
?.name
|
||||||
?: fileServers[0].server.name,
|
?: fileServers[0].name,
|
||||||
options = fileServerOptions,
|
options = fileServerOptions,
|
||||||
onSelect = { postViewModel.selectedServer = fileServers[it] },
|
onSelect = { postViewModel.selectedServer = fileServers[it] },
|
||||||
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
|
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
|
||||||
|
@ -42,10 +42,14 @@ import com.vitorpamplona.amethyst.model.Account
|
|||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||||
import com.vitorpamplona.amethyst.service.FileHeader
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
import com.vitorpamplona.amethyst.service.LocationState
|
import com.vitorpamplona.amethyst.service.LocationState
|
||||||
|
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.ui.components.Split
|
import com.vitorpamplona.amethyst.ui.components.Split
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
@ -61,7 +65,6 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
|
|||||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||||
import com.vitorpamplona.quartz.events.CommentEvent
|
import com.vitorpamplona.quartz.events.CommentEvent
|
||||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||||
import com.vitorpamplona.quartz.events.Dimension
|
|
||||||
import com.vitorpamplona.quartz.events.DraftEvent
|
import com.vitorpamplona.quartz.events.DraftEvent
|
||||||
import com.vitorpamplona.quartz.events.Event
|
import com.vitorpamplona.quartz.events.Event
|
||||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||||
@ -869,7 +872,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
sensitiveContent: Boolean,
|
sensitiveContent: Boolean,
|
||||||
mediaQuality: Int,
|
mediaQuality: Int,
|
||||||
isPrivate: Boolean = false,
|
isPrivate: Boolean = false,
|
||||||
server: ServerOption,
|
server: ServerName,
|
||||||
onError: (title: String, message: String) -> Unit,
|
onError: (title: String, message: String) -> Unit,
|
||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
@ -886,7 +889,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
contentType,
|
contentType,
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
onReady = { fileUri, contentType, size ->
|
onReady = { fileUri, contentType, size ->
|
||||||
if (server.isNip95) {
|
if (server.type == ServerType.NIP95) {
|
||||||
contentResolver.openInputStream(fileUri)?.use {
|
contentResolver.openInputStream(fileUri)?.use {
|
||||||
createNIP95Record(
|
createNIP95Record(
|
||||||
it.readBytes(),
|
it.readBytes(),
|
||||||
@ -899,7 +902,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else if (server.type == ServerType.NIP96) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val result =
|
val result =
|
||||||
@ -910,13 +913,52 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
size = size,
|
size = size,
|
||||||
alt = alt,
|
alt = alt,
|
||||||
sensitiveContent = if (sensitiveContent) "" else null,
|
sensitiveContent = if (sensitiveContent) "" else null,
|
||||||
server = server.server,
|
server = server,
|
||||||
contentResolver = contentResolver,
|
contentResolver = contentResolver,
|
||||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
onProgress = {},
|
onProgress = {},
|
||||||
context = context,
|
context = context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createNIP94Record(
|
||||||
|
uploadingResult = result,
|
||||||
|
localContentType = contentType,
|
||||||
|
alt = alt,
|
||||||
|
sensitiveContent = sensitiveContent,
|
||||||
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
|
onError = {
|
||||||
|
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||||
|
},
|
||||||
|
context = context,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.e(
|
||||||
|
"ImageUploader",
|
||||||
|
"Failed to upload ${e.message}",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
isUploadingImage = false
|
||||||
|
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (server.type == ServerType.Blossom) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
BlossomUploader(account)
|
||||||
|
.uploadImage(
|
||||||
|
uri = fileUri,
|
||||||
|
contentType = contentType,
|
||||||
|
size = size,
|
||||||
|
alt = alt,
|
||||||
|
sensitiveContent = if (sensitiveContent) "" else null,
|
||||||
|
server = server,
|
||||||
|
contentResolver = contentResolver,
|
||||||
|
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||||
|
context = context,
|
||||||
|
)
|
||||||
|
|
||||||
createNIP94Record(
|
createNIP94Record(
|
||||||
uploadingResult = result,
|
uploadingResult = result,
|
||||||
localContentType = contentType,
|
localContentType = contentType,
|
||||||
@ -1182,7 +1224,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
contentToAddUrl == null
|
contentToAddUrl == null
|
||||||
|
|
||||||
suspend fun createNIP94Record(
|
suspend fun createNIP94Record(
|
||||||
uploadingResult: Nip96Uploader.PartialEvent,
|
uploadingResult: MediaUploadResult,
|
||||||
localContentType: String?,
|
localContentType: String?,
|
||||||
alt: String?,
|
alt: String?,
|
||||||
sensitiveContent: Boolean,
|
sensitiveContent: Boolean,
|
||||||
@ -1190,31 +1232,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
onError: (message: String) -> Unit,
|
onError: (message: String) -> Unit,
|
||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
// Images don't seem to be ready immediately after upload
|
if (uploadingResult.url.isNullOrBlank()) {
|
||||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
|
||||||
val remoteMimeType =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
val originalHash =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
val dim =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "dim" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
?.let { Dimension.parse(it) }
|
|
||||||
val magnet =
|
|
||||||
uploadingResult.tags
|
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
|
|
||||||
?.get(1)
|
|
||||||
?.ifBlank { null }
|
|
||||||
|
|
||||||
if (imageUrl.isNullOrBlank()) {
|
|
||||||
Log.e("ImageDownload", "Couldn't download image from server")
|
Log.e("ImageDownload", "Couldn't download image from server")
|
||||||
cancel()
|
cancel()
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
@ -1223,16 +1241,16 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FileHeader.prepare(
|
FileHeader.prepare(
|
||||||
fileUrl = imageUrl,
|
fileUrl = uploadingResult.url,
|
||||||
mimeType = remoteMimeType ?: localContentType,
|
mimeType = uploadingResult.type ?: localContentType,
|
||||||
dimPrecomputed = dim,
|
dimPrecomputed = uploadingResult.dimension,
|
||||||
forceProxy = forceProxy(imageUrl),
|
forceProxy = forceProxy(uploadingResult.url),
|
||||||
onReady = { header: FileHeader ->
|
onReady = { header: FileHeader ->
|
||||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
|
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
|
||||||
|
|
||||||
message = message.insertUrlAtCursor(imageUrl)
|
message = message.insertUrlAtCursor(uploadingResult.url)
|
||||||
urlPreview = findUrlInMessage()
|
urlPreview = findUrlInMessage()
|
||||||
saveDraft()
|
saveDraft()
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
@ -181,25 +183,38 @@ class NewUserMetadataViewModel : ViewModel() {
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val result =
|
val result =
|
||||||
Nip96Uploader(account)
|
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
|
||||||
.uploadImage(
|
Nip96Uploader(account)
|
||||||
uri = fileUri,
|
.uploadImage(
|
||||||
contentType = contentType,
|
uri = fileUri,
|
||||||
size = size,
|
contentType = contentType,
|
||||||
alt = null,
|
size = size,
|
||||||
sensitiveContent = null,
|
alt = null,
|
||||||
server = account.settings.defaultFileServer,
|
sensitiveContent = null,
|
||||||
contentResolver = contentResolver,
|
server = account.settings.defaultFileServer,
|
||||||
forceProxy = account::shouldUseTorForNIP96,
|
contentResolver = contentResolver,
|
||||||
onProgress = {},
|
forceProxy = account::shouldUseTorForNIP96,
|
||||||
context = context,
|
onProgress = {},
|
||||||
)
|
context = context,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BlossomUploader(account)
|
||||||
|
.uploadImage(
|
||||||
|
uri = fileUri,
|
||||||
|
contentType = contentType,
|
||||||
|
size = size,
|
||||||
|
alt = null,
|
||||||
|
sensitiveContent = null,
|
||||||
|
server = account.settings.defaultFileServer,
|
||||||
|
contentResolver = contentResolver,
|
||||||
|
forceProxy = account::shouldUseTorForNIP96,
|
||||||
|
context = context,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
if (result.url != null) {
|
||||||
|
|
||||||
if (url != null) {
|
|
||||||
onUploading(false)
|
onUploading(false)
|
||||||
onUploaded(url)
|
onUploaded(result.url)
|
||||||
} else {
|
} else {
|
||||||
onUploading(false)
|
onUploading(false)
|
||||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||||
|
@ -54,13 +54,14 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategory
|
||||||
import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton
|
import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
|
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
|
||||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||||
@ -73,11 +74,15 @@ fun MediaServersListView(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val mediaServersViewModel: MediaServersViewModel = viewModel()
|
val nip96ServersViewModel: NIP96ServersViewModel = viewModel()
|
||||||
val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle()
|
val nip96ServersState by nip96ServersViewModel.fileServers.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val blossomServersViewModel: BlossomServersViewModel = viewModel()
|
||||||
|
val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(key1 = Unit) {
|
LaunchedEffect(key1 = Unit) {
|
||||||
mediaServersViewModel.load(accountViewModel.account)
|
nip96ServersViewModel.load(accountViewModel.account)
|
||||||
|
blossomServersViewModel.load(accountViewModel.account)
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
@ -102,7 +107,8 @@ fun MediaServersListView(
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
CloseButton(
|
CloseButton(
|
||||||
onPress = {
|
onPress = {
|
||||||
mediaServersViewModel.refresh()
|
nip96ServersViewModel.refresh()
|
||||||
|
blossomServersViewModel.refresh()
|
||||||
onClose()
|
onClose()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -110,7 +116,8 @@ fun MediaServersListView(
|
|||||||
actions = {
|
actions = {
|
||||||
SaveButton(
|
SaveButton(
|
||||||
onPost = {
|
onPost = {
|
||||||
mediaServersViewModel.saveFileServers()
|
nip96ServersViewModel.saveFileServers()
|
||||||
|
blossomServersViewModel.saveFileServers()
|
||||||
onClose()
|
onClose()
|
||||||
},
|
},
|
||||||
isActive = true,
|
isActive = true,
|
||||||
@ -148,17 +155,46 @@ fun MediaServersListView(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
contentPadding = FeedPadding,
|
contentPadding = FeedPadding,
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
SettingsCategory(
|
||||||
|
stringRes(R.string.media_servers_nip96_section),
|
||||||
|
stringRes(R.string.media_servers_nip96_explainer),
|
||||||
|
Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
renderMediaServerList(
|
renderMediaServerList(
|
||||||
mediaServersState = mediaServersState,
|
mediaServersState = nip96ServersState,
|
||||||
|
editLabel = R.string.add_a_nip96_server,
|
||||||
|
emptyLabel = R.string.no_nip96_server_message,
|
||||||
onAddServer = { server ->
|
onAddServer = { server ->
|
||||||
mediaServersViewModel.addServer(server)
|
nip96ServersViewModel.addServer(server)
|
||||||
},
|
},
|
||||||
onDeleteServer = {
|
onDeleteServer = {
|
||||||
mediaServersViewModel.removeServer(serverUrl = it)
|
nip96ServersViewModel.removeServer(serverUrl = it)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
Nip96MediaServers.DEFAULT.let {
|
item {
|
||||||
|
SettingsCategory(
|
||||||
|
stringRes(R.string.media_servers_blossom_section),
|
||||||
|
stringRes(R.string.media_servers_blossom_explainer),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMediaServerList(
|
||||||
|
mediaServersState = blossomServersState,
|
||||||
|
editLabel = R.string.add_a_blossom_server,
|
||||||
|
emptyLabel = R.string.no_blossom_server_message,
|
||||||
|
onAddServer = { server ->
|
||||||
|
blossomServersViewModel.addServer(server)
|
||||||
|
},
|
||||||
|
onDeleteServer = {
|
||||||
|
blossomServersViewModel.removeServer(serverUrl = it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_MEDIA_SERVERS.let {
|
||||||
item {
|
item {
|
||||||
SettingsCategoryWithButton(
|
SettingsCategoryWithButton(
|
||||||
title = stringRes(id = R.string.built_in_media_servers_title),
|
title = stringRes(id = R.string.built_in_media_servers_title),
|
||||||
@ -166,7 +202,13 @@ fun MediaServersListView(
|
|||||||
action = {
|
action = {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
mediaServersViewModel.addServerList(it.map { s -> s.baseUrl })
|
nip96ServersViewModel.addServerList(
|
||||||
|
it.mapNotNull { s -> if (s.type == ServerType.NIP96) s.baseUrl else null },
|
||||||
|
)
|
||||||
|
|
||||||
|
blossomServersViewModel.addServerList(
|
||||||
|
it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(text = stringRes(id = R.string.use_default_servers))
|
Text(text = stringRes(id = R.string.use_default_servers))
|
||||||
@ -176,7 +218,7 @@ fun MediaServersListView(
|
|||||||
}
|
}
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
it,
|
it,
|
||||||
key = { index: Int, server: Nip96MediaServers.ServerName ->
|
key = { index: Int, server: ServerName ->
|
||||||
server.baseUrl
|
server.baseUrl
|
||||||
},
|
},
|
||||||
) { index, server ->
|
) { index, server ->
|
||||||
@ -184,11 +226,19 @@ fun MediaServersListView(
|
|||||||
serverEntry = server,
|
serverEntry = server,
|
||||||
isAmethystDefault = true,
|
isAmethystDefault = true,
|
||||||
onAddOrDelete = { serverUrl ->
|
onAddOrDelete = { serverUrl ->
|
||||||
mediaServersViewModel.addServer(serverUrl)
|
if (server.type == ServerType.NIP96) {
|
||||||
|
nip96ServersViewModel.addServer(serverUrl)
|
||||||
|
} else if (server.type == ServerType.Blossom) {
|
||||||
|
blossomServersViewModel.addServer(serverUrl)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(DoubleHorzSpacer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -196,21 +246,23 @@ fun MediaServersListView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun LazyListScope.renderMediaServerList(
|
fun LazyListScope.renderMediaServerList(
|
||||||
mediaServersState: List<Nip96MediaServers.ServerName>,
|
mediaServersState: List<ServerName>,
|
||||||
|
editLabel: Int,
|
||||||
|
emptyLabel: Int,
|
||||||
onAddServer: (String) -> Unit,
|
onAddServer: (String) -> Unit,
|
||||||
onDeleteServer: (String) -> Unit,
|
onDeleteServer: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (mediaServersState.isEmpty()) {
|
if (mediaServersState.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(id = R.string.no_media_server_message),
|
text = stringRes(id = emptyLabel),
|
||||||
modifier = DoubleVertPadding,
|
modifier = DoubleVertPadding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
mediaServersState,
|
mediaServersState,
|
||||||
key = { index: Int, server: Nip96MediaServers.ServerName ->
|
key = { index: Int, server: ServerName ->
|
||||||
server.baseUrl
|
server.baseUrl
|
||||||
},
|
},
|
||||||
) { index, entry ->
|
) { index, entry ->
|
||||||
@ -225,7 +277,7 @@ fun LazyListScope.renderMediaServerList(
|
|||||||
|
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = StdVertSpacer)
|
Spacer(modifier = StdVertSpacer)
|
||||||
MediaServerEditField {
|
MediaServerEditField(editLabel) {
|
||||||
onAddServer(it)
|
onAddServer(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -234,7 +286,7 @@ fun LazyListScope.renderMediaServerList(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MediaServerEntry(
|
fun MediaServerEntry(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
serverEntry: Nip96MediaServers.ServerName,
|
serverEntry: ServerName,
|
||||||
isAmethystDefault: Boolean = false,
|
isAmethystDefault: Boolean = false,
|
||||||
onAddOrDelete: (serverUrl: String) -> Unit,
|
onAddOrDelete: (serverUrl: String) -> Unit,
|
||||||
) {
|
) {
|
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2024 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.czeal.rfc3986.URIReference
|
||||||
|
|
||||||
|
class BlossomServersViewModel : ViewModel() {
|
||||||
|
lateinit var account: Account
|
||||||
|
|
||||||
|
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
|
||||||
|
val fileServers = _fileServers.asStateFlow()
|
||||||
|
private var isModified = false
|
||||||
|
|
||||||
|
fun load(account: Account) {
|
||||||
|
this.account = account
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
isModified = false
|
||||||
|
_fileServers.update {
|
||||||
|
val obtainedFileServers = obtainFileServers() ?: emptyList()
|
||||||
|
obtainedFileServers.mapNotNull { serverUrl ->
|
||||||
|
try {
|
||||||
|
ServerName(
|
||||||
|
URIReference.parse(serverUrl).host.value,
|
||||||
|
serverUrl,
|
||||||
|
ServerType.Blossom,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d("MediaServersViewModel", "Invalid URL in Blossom server list")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addServerList(serverList: List<String>) {
|
||||||
|
serverList.forEach { serverUrl ->
|
||||||
|
addServer(serverUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addServer(serverUrl: String) {
|
||||||
|
val normalizedUrl =
|
||||||
|
try {
|
||||||
|
URIReference.parse(serverUrl.trim()).normalize().toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
serverUrl
|
||||||
|
}
|
||||||
|
val serverNameReference =
|
||||||
|
try {
|
||||||
|
URIReference.parse(normalizedUrl).host.value
|
||||||
|
} catch (e: Exception) {
|
||||||
|
normalizedUrl
|
||||||
|
}
|
||||||
|
val serverRef =
|
||||||
|
ServerName(
|
||||||
|
serverNameReference,
|
||||||
|
normalizedUrl,
|
||||||
|
ServerType.Blossom,
|
||||||
|
)
|
||||||
|
if (_fileServers.value.contains(serverRef)) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
_fileServers.update {
|
||||||
|
it.plus(serverRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isModified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeServer(
|
||||||
|
name: String = "",
|
||||||
|
serverUrl: String,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
|
||||||
|
_fileServers.update {
|
||||||
|
it.minus(
|
||||||
|
ServerName(serverName, serverUrl, ServerType.Blossom),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isModified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAllServers() {
|
||||||
|
_fileServers.update { emptyList() }
|
||||||
|
isModified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveFileServers() {
|
||||||
|
if (isModified) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val serverList = _fileServers.value.map { it.baseUrl }
|
||||||
|
account.sendBlossomServersList(serverList)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun obtainFileServers(): List<String>? = account.getBlossomServersList()?.servers()
|
||||||
|
}
|
@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.encoders.HttpUrlFormatter
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaServerEditField(
|
fun MediaServerEditField(
|
||||||
|
label: Int = R.string.add_a_nip96_server,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onAddServer: (String) -> Unit,
|
onAddServer: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -63,7 +64,7 @@ fun MediaServerEditField(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
label = { Text(text = stringRes(R.string.add_a_nip96_server)) },
|
label = { Text(text = stringRes(label)) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
value = url,
|
value = url,
|
||||||
onValueChange = { url = it },
|
onValueChange = { url = it },
|
||||||
|
@ -24,7 +24,6 @@ import android.util.Log
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@ -32,10 +31,10 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.czeal.rfc3986.URIReference
|
import org.czeal.rfc3986.URIReference
|
||||||
|
|
||||||
class MediaServersViewModel : ViewModel() {
|
class NIP96ServersViewModel : ViewModel() {
|
||||||
lateinit var account: Account
|
lateinit var account: Account
|
||||||
|
|
||||||
private val _fileServers = MutableStateFlow<List<Nip96MediaServers.ServerName>>(emptyList())
|
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
|
||||||
val fileServers = _fileServers.asStateFlow()
|
val fileServers = _fileServers.asStateFlow()
|
||||||
private var isModified = false
|
private var isModified = false
|
||||||
|
|
||||||
@ -50,11 +49,11 @@ class MediaServersViewModel : ViewModel() {
|
|||||||
val obtainedFileServers = obtainFileServers() ?: emptyList()
|
val obtainedFileServers = obtainFileServers() ?: emptyList()
|
||||||
obtainedFileServers.mapNotNull { serverUrl ->
|
obtainedFileServers.mapNotNull { serverUrl ->
|
||||||
try {
|
try {
|
||||||
Nip96MediaServers
|
ServerName(
|
||||||
.ServerName(
|
URIReference.parse(serverUrl).host.value,
|
||||||
URIReference.parse(serverUrl).host.value,
|
serverUrl,
|
||||||
serverUrl,
|
ServerType.NIP96,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list")
|
Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list")
|
||||||
null
|
null
|
||||||
@ -82,7 +81,7 @@ class MediaServersViewModel : ViewModel() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
normalizedUrl
|
normalizedUrl
|
||||||
}
|
}
|
||||||
val serverRef = Nip96MediaServers.ServerName(serverNameReference, normalizedUrl)
|
val serverRef = ServerName(serverNameReference, normalizedUrl, ServerType.NIP96)
|
||||||
if (_fileServers.value.contains(serverRef)) {
|
if (_fileServers.value.contains(serverRef)) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
@ -101,7 +100,7 @@ class MediaServersViewModel : ViewModel() {
|
|||||||
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
|
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
|
||||||
_fileServers.update {
|
_fileServers.update {
|
||||||
it.minus(
|
it.minus(
|
||||||
Nip96MediaServers.ServerName(serverName, serverUrl),
|
ServerName(serverName, serverUrl, ServerType.NIP96),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
isModified = true
|
isModified = true
|
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2024 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||||
|
|
||||||
|
data class ServerName(
|
||||||
|
val name: String,
|
||||||
|
val baseUrl: String,
|
||||||
|
val type: ServerType = ServerType.NIP96,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ServerType {
|
||||||
|
NIP96,
|
||||||
|
NIP95,
|
||||||
|
Blossom,
|
||||||
|
}
|
||||||
|
|
||||||
|
val DEFAULT_MEDIA_SERVERS: List<ServerName> =
|
||||||
|
listOf(
|
||||||
|
ServerName("Nostr.Build", "https://nostr.build", ServerType.NIP96),
|
||||||
|
ServerName("NostrCheck.me", "https://nostrcheck.me", ServerType.NIP96),
|
||||||
|
ServerName("NostPic", "https://nostpic.com", ServerType.NIP96),
|
||||||
|
ServerName("Sovbit", "https://files.sovbit.host", ServerType.NIP96),
|
||||||
|
ServerName("Void.cat", "https://void.cat", ServerType.NIP96),
|
||||||
|
ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom),
|
||||||
|
ServerName("NostrCheck.me (Blossom)", "https://cdn.nostrcheck.me", ServerType.Blossom),
|
||||||
|
ServerName("Primal", "https://blossom.primal.net", ServerType.Blossom),
|
||||||
|
)
|
@ -58,9 +58,9 @@ import androidx.compose.ui.text.Placeholder
|
|||||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import coil3.annotation.ExperimentalCoilApi
|
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
import coil3.compose.SubcomposeAsyncImage
|
import coil3.compose.SubcomposeAsyncImage
|
||||||
@ -75,7 +75,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
|
|||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||||
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
import com.vitorpamplona.amethyst.service.Blurhash
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||||
@ -103,7 +103,6 @@ import kotlinx.collections.immutable.ImmutableList
|
|||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@ -513,6 +512,8 @@ fun ImageUrlWithDownloadButton(
|
|||||||
text = annotatedTermsString,
|
text = annotatedTermsString,
|
||||||
modifier = pressIndicator,
|
modifier = pressIndicator,
|
||||||
inlineContent = inlineContent,
|
inlineContent = inlineContent,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,20 +561,6 @@ fun aspectRatio(dim: Dimension?): Float? {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
||||||
var cnt by remember { mutableStateOf<BaseMediaContent?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
launch(Dispatchers.IO) {
|
|
||||||
delay(200)
|
|
||||||
cnt = content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
|
|
||||||
val uri = LocalUriHandler.current
|
val uri = LocalUriHandler.current
|
||||||
|
|
||||||
val primary = MaterialTheme.colorScheme.primary
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
@ -620,6 +607,8 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
|
|||||||
text = annotatedTermsString,
|
text = annotatedTermsString,
|
||||||
modifier = pressIndicator,
|
modifier = pressIndicator,
|
||||||
inlineContent = inlineContent,
|
inlineContent = inlineContent,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -644,17 +633,8 @@ fun DisplayBlurHash(
|
|||||||
) {
|
) {
|
||||||
if (blurhash == null) return
|
if (blurhash == null) return
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val model =
|
|
||||||
remember {
|
|
||||||
BlurHashRequester.imageRequest(
|
|
||||||
context,
|
|
||||||
blurhash,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = model,
|
model = Blurhash(blurhash),
|
||||||
contentDescription = description,
|
contentDescription = description,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@ -753,7 +733,6 @@ fun ShareImageAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoilApi::class)
|
|
||||||
private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
|
private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
|
||||||
if (content.hash == null) return null
|
if (content.hash == null) return null
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ class MarkdownMediaRenderer(
|
|||||||
) {
|
) {
|
||||||
if (canPreview) {
|
if (canPreview) {
|
||||||
val content =
|
val content =
|
||||||
parser.parseMediaUrl(
|
parser.createMediaContent(
|
||||||
fullUrl = uri,
|
fullUrl = uri,
|
||||||
eventTags = tags ?: EmptyTagList,
|
eventTags = tags ?: EmptyTagList,
|
||||||
description = title?.ifEmpty { null } ?: startOfText,
|
description = title?.ifEmpty { null } ?: startOfText,
|
||||||
@ -109,7 +109,7 @@ class MarkdownMediaRenderer(
|
|||||||
uri: String,
|
uri: String,
|
||||||
richTextStringBuilder: RichTextString.Builder,
|
richTextStringBuilder: RichTextString.Builder,
|
||||||
) {
|
) {
|
||||||
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
|
val content = parser.createMediaContent(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
|
||||||
|
|
||||||
if (canPreview) {
|
if (canPreview) {
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
|
@ -91,6 +91,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@ -134,17 +135,17 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
|||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.LocationState
|
import com.vitorpamplona.amethyst.service.LocationState
|
||||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPollOption
|
import com.vitorpamplona.amethyst.ui.actions.NewPollOption
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
|
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog
|
import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog
|
||||||
import com.vitorpamplona.amethyst.ui.actions.ServerOption
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||||
import com.vitorpamplona.amethyst.ui.actions.getPhotoUri
|
import com.vitorpamplona.amethyst.ui.actions.getPhotoUri
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||||
@ -184,7 +185,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@ -520,8 +520,8 @@ fun NewPostScreen(
|
|||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||||
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||||
if (!server.isNip95) {
|
if (server.type != ServerType.NIP95) {
|
||||||
accountViewModel.account.settings.changeDefaultFileServer(server.server)
|
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancel = { postViewModel.contentToAddUrl = null },
|
onCancel = { postViewModel.contentToAddUrl = null },
|
||||||
@ -1720,8 +1720,8 @@ fun CreateButton(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ImageVideoDescription(
|
fun ImageVideoDescription(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
defaultServer: Nip96MediaServers.ServerName,
|
defaultServer: ServerName,
|
||||||
onAdd: (String, ServerOption, Boolean, Int) -> Unit,
|
onAdd: (String, ServerName, Boolean, Int) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onError: (Int) -> Unit,
|
onError: (Int) -> Unit,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
@ -1732,49 +1732,25 @@ fun ImageVideoDescription(
|
|||||||
val isImage = mediaType.startsWith("image")
|
val isImage = mediaType.startsWith("image")
|
||||||
val isVideo = mediaType.startsWith("video")
|
val isVideo = mediaType.startsWith("video")
|
||||||
|
|
||||||
val listOfNip96ServersNote =
|
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
|
||||||
accountViewModel.account
|
|
||||||
.getFileServersNote()
|
|
||||||
.live()
|
|
||||||
.metadata
|
|
||||||
.observeAsState()
|
|
||||||
|
|
||||||
val fileServers =
|
val fileServers by accountViewModel.account.liveServerList.collectAsState()
|
||||||
(
|
|
||||||
(listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map {
|
|
||||||
ServerOption(
|
|
||||||
Nip96MediaServers.ServerName(
|
|
||||||
it,
|
|
||||||
it,
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
} ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) }
|
|
||||||
) +
|
|
||||||
listOf(
|
|
||||||
ServerOption(
|
|
||||||
Nip96MediaServers.ServerName(
|
|
||||||
"NIP95",
|
|
||||||
stringRes(id = R.string.upload_server_relays_nip95),
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val fileServerOptions =
|
val fileServerOptions =
|
||||||
remember {
|
remember(fileServers) {
|
||||||
fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList()
|
fileServers
|
||||||
|
.map {
|
||||||
|
if (it.type == ServerType.NIP95) {
|
||||||
|
TitleExplainer(it.name, nip95description)
|
||||||
|
} else {
|
||||||
|
TitleExplainer(it.name, it.baseUrl)
|
||||||
|
}
|
||||||
|
}.toImmutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedServer by remember {
|
var selectedServer by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ServerOption(
|
fileServers.firstOrNull { it == defaultServer } ?: fileServers[0],
|
||||||
fileServers
|
|
||||||
.firstOrNull { it.server == defaultServer }
|
|
||||||
?.server
|
|
||||||
?: fileServers[0].server,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
var message by remember { mutableStateOf("") }
|
var message by remember { mutableStateOf("") }
|
||||||
@ -1903,10 +1879,9 @@ fun ImageVideoDescription(
|
|||||||
label = stringRes(id = R.string.file_server),
|
label = stringRes(id = R.string.file_server),
|
||||||
placeholder =
|
placeholder =
|
||||||
fileServers
|
fileServers
|
||||||
.firstOrNull { it.server == defaultServer }
|
.firstOrNull { it == defaultServer }
|
||||||
?.server
|
|
||||||
?.name
|
?.name
|
||||||
?: fileServers[0].server.name,
|
?: fileServers[0].name,
|
||||||
options = fileServerOptions,
|
options = fileServerOptions,
|
||||||
onSelect = { selectedServer = fileServers[it] },
|
onSelect = { selectedServer = fileServers[it] },
|
||||||
modifier =
|
modifier =
|
||||||
|
@ -113,7 +113,6 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
|||||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.actions.ServerOption
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||||
@ -518,7 +517,7 @@ fun EditFieldRow(
|
|||||||
sensitiveContent = false,
|
sensitiveContent = false,
|
||||||
// Use MEDIUM quality
|
// Use MEDIUM quality
|
||||||
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
|
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
|
||||||
server = ServerOption(accountViewModel.account.settings.defaultFileServer, false),
|
server = accountViewModel.account.settings.defaultFileServer,
|
||||||
onError = accountViewModel::toast,
|
onError = accountViewModel::toast,
|
||||||
context = context,
|
context = context,
|
||||||
)
|
)
|
||||||
|
@ -87,7 +87,6 @@ import com.vitorpamplona.amethyst.model.User
|
|||||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.actions.ServerOption
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||||
@ -589,7 +588,7 @@ fun PrivateMessageEditFieldRow(
|
|||||||
// use MEDIUM quality
|
// use MEDIUM quality
|
||||||
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
|
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
|
||||||
isPrivate = isPrivate,
|
isPrivate = isPrivate,
|
||||||
server = ServerOption(accountViewModel.account.settings.defaultFileServer, false),
|
server = accountViewModel.account.settings.defaultFileServer,
|
||||||
onError = accountViewModel::toast,
|
onError = accountViewModel::toast,
|
||||||
context = context,
|
context = context,
|
||||||
)
|
)
|
||||||
|
@ -391,7 +391,10 @@
|
|||||||
|
|
||||||
<string name="media_servers">Media Servers</string>
|
<string name="media_servers">Media Servers</string>
|
||||||
<string name="set_preferred_media_servers">Set your preferred media upload servers.</string>
|
<string name="set_preferred_media_servers">Set your preferred media upload servers.</string>
|
||||||
<string name="no_media_server_message">You have no custom media servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
|
||||||
|
<string name="no_nip96_server_message">You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
||||||
|
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
||||||
|
|
||||||
<string name="built_in_media_servers_title">Built-in Media Servers</string>
|
<string name="built_in_media_servers_title">Built-in Media Servers</string>
|
||||||
<string name="built_in_servers_description">Amethyst\'s default list. You can add them individually or add the list.</string>
|
<string name="built_in_servers_description">Amethyst\'s default list. You can add them individually or add the list.</string>
|
||||||
<string name="use_default_servers">Use Default List</string>
|
<string name="use_default_servers">Use Default List</string>
|
||||||
@ -1046,7 +1049,14 @@
|
|||||||
<string name="http_status_508">Loop Detected - The server detects an infinite loop while processing the request</string>
|
<string name="http_status_508">Loop Detected - The server detects an infinite loop while processing the request</string>
|
||||||
<string name="http_status_511">Network Authentication Required - The client must be authenticated to access the network</string>
|
<string name="http_status_511">Network Authentication Required - The client must be authenticated to access the network</string>
|
||||||
|
|
||||||
|
<string name="media_servers_nip96_section">NIP-96 Servers</string>
|
||||||
|
<string name="media_servers_nip96_explainer">Add as many servers as you want. You can choose which one to use later when uploading your picture</string>
|
||||||
|
|
||||||
|
<string name="media_servers_blossom_section">Blossom Servers</string>
|
||||||
|
<string name="media_servers_blossom_explainer">Add as many servers as you want. You can choose which one to use later when uploading your picture</string>
|
||||||
|
|
||||||
<string name="add_a_nip96_server">Add a NIP-96 Server</string>
|
<string name="add_a_nip96_server">Add a NIP-96 Server</string>
|
||||||
|
<string name="add_a_blossom_server">Add a Blossom Server</string>
|
||||||
<string name="delete_all">Delete all</string>
|
<string name="delete_all">Delete all</string>
|
||||||
<string name="delete_all_drafts_confirmation">Are you sure you want to delete all drafts?</string>
|
<string name="delete_all_drafts_confirmation">Are you sure you want to delete all drafts?</string>
|
||||||
<string name="and_more" translatable="false">" +%1$s"</string>
|
<string name="and_more" translatable="false">" +%1$s"</string>
|
||||||
|
@ -43,58 +43,54 @@ import java.util.regex.Pattern
|
|||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
class RichTextParser {
|
class RichTextParser {
|
||||||
fun createImageContent(
|
fun createMediaContent(
|
||||||
fullUrl: String,
|
|
||||||
eventTags: ImmutableListOfLists<String>,
|
|
||||||
description: String?,
|
|
||||||
callbackUri: String? = null,
|
|
||||||
): MediaUrlImage {
|
|
||||||
val frags = Nip54InlineMetadata().parse(fullUrl)
|
|
||||||
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
|
|
||||||
|
|
||||||
return MediaUrlImage(
|
|
||||||
url = fullUrl,
|
|
||||||
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
|
|
||||||
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
|
|
||||||
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
|
|
||||||
dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) },
|
|
||||||
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
|
||||||
uri = callbackUri,
|
|
||||||
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createVideoContent(
|
|
||||||
fullUrl: String,
|
|
||||||
eventTags: ImmutableListOfLists<String>,
|
|
||||||
description: String?,
|
|
||||||
callbackUri: String? = null,
|
|
||||||
): MediaUrlVideo {
|
|
||||||
val frags = Nip54InlineMetadata().parse(fullUrl)
|
|
||||||
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
|
|
||||||
return MediaUrlVideo(
|
|
||||||
url = fullUrl,
|
|
||||||
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
|
|
||||||
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
|
|
||||||
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
|
|
||||||
dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) },
|
|
||||||
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
|
||||||
uri = callbackUri,
|
|
||||||
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseMediaUrl(
|
|
||||||
fullUrl: String,
|
fullUrl: String,
|
||||||
eventTags: ImmutableListOfLists<String>,
|
eventTags: ImmutableListOfLists<String>,
|
||||||
description: String?,
|
description: String?,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
): MediaUrlContent? {
|
): MediaUrlContent? {
|
||||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
val frags = Nip54InlineMetadata().parse(fullUrl)
|
||||||
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
|
||||||
createImageContent(fullUrl, eventTags, description, callbackUri)
|
|
||||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
val contentType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE]
|
||||||
createVideoContent(fullUrl, eventTags, description, callbackUri)
|
|
||||||
|
val isImage: Boolean
|
||||||
|
val isVideo: Boolean
|
||||||
|
|
||||||
|
if (contentType != null) {
|
||||||
|
isImage = contentType.startsWith("image/")
|
||||||
|
isVideo = contentType.startsWith("video/")
|
||||||
|
} else if (fullUrl.startsWith("data:")) {
|
||||||
|
isImage = fullUrl.startsWith("data:image/")
|
||||||
|
isVideo = fullUrl.startsWith("data:video/")
|
||||||
|
} else {
|
||||||
|
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||||
|
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
isVideo = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (isImage) {
|
||||||
|
MediaUrlImage(
|
||||||
|
url = fullUrl,
|
||||||
|
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
|
||||||
|
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
|
||||||
|
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
|
||||||
|
dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) },
|
||||||
|
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
||||||
|
uri = callbackUri,
|
||||||
|
mimeType = contentType,
|
||||||
|
)
|
||||||
|
} else if (isVideo) {
|
||||||
|
MediaUrlVideo(
|
||||||
|
url = fullUrl,
|
||||||
|
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
|
||||||
|
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
|
||||||
|
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
|
||||||
|
dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) },
|
||||||
|
contentWarning = frags["content-warning"] ?: tags["content-warning"],
|
||||||
|
uri = callbackUri,
|
||||||
|
mimeType = contentType,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -137,7 +133,7 @@ class RichTextParser {
|
|||||||
val urlSet = parseValidUrls(content)
|
val urlSet = parseValidUrls(content)
|
||||||
|
|
||||||
val imagesForPager =
|
val imagesForPager =
|
||||||
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags, content, callbackUri) }.associateBy { it.url }
|
urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, tags, content, callbackUri) }.associateBy { it.url }
|
||||||
|
|
||||||
val emojiMap = Nip30CustomEmoji.createEmojiMap(tags)
|
val emojiMap = Nip30CustomEmoji.createEmojiMap(tags)
|
||||||
|
|
||||||
@ -148,7 +144,7 @@ class RichTextParser {
|
|||||||
val imagesForPagerWithBase64 =
|
val imagesForPagerWithBase64 =
|
||||||
imagesForPager +
|
imagesForPager +
|
||||||
base64Images
|
base64Images
|
||||||
.map { createImageContent(it.segmentText, tags, content, callbackUri) }
|
.mapNotNull { createMediaContent(it.segmentText, tags, content, callbackUri) }
|
||||||
.associateBy { it.url }
|
.associateBy { it.url }
|
||||||
|
|
||||||
return RichTextViewerState(
|
return RichTextViewerState(
|
||||||
|
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 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.quartz.events
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.encoders.HexKey
|
||||||
|
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class BlossomAuthorizationEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
companion object {
|
||||||
|
const val KIND = 24242
|
||||||
|
|
||||||
|
fun createGetAuth(
|
||||||
|
hash: HexKey,
|
||||||
|
alt: String,
|
||||||
|
signer: NostrSigner,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||||
|
) = createAuth("get", hash, alt, signer, createdAt, onReady)
|
||||||
|
|
||||||
|
fun createListAuth(
|
||||||
|
signer: NostrSigner,
|
||||||
|
alt: String,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||||
|
) = createAuth("list", null, alt, signer, createdAt, onReady)
|
||||||
|
|
||||||
|
fun createDeleteAuth(
|
||||||
|
hash: HexKey,
|
||||||
|
alt: String,
|
||||||
|
signer: NostrSigner,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||||
|
) = createAuth("delete", hash, alt, signer, createdAt, onReady)
|
||||||
|
|
||||||
|
fun createUploadAuth(
|
||||||
|
hash: HexKey,
|
||||||
|
alt: String,
|
||||||
|
signer: NostrSigner,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||||
|
) = createAuth("upload", hash, alt, signer, createdAt, onReady)
|
||||||
|
|
||||||
|
private fun createAuth(
|
||||||
|
type: String,
|
||||||
|
hash: HexKey?,
|
||||||
|
alt: String,
|
||||||
|
signer: NostrSigner,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
val tags =
|
||||||
|
listOfNotNull(
|
||||||
|
arrayOf("t", type),
|
||||||
|
arrayOf("expiration", TimeUtils.oneHourAhead().toString()),
|
||||||
|
hash?.let { arrayOf("x", it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
signer.sign(createdAt, KIND, tags.toTypedArray(), alt, onReady)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 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.quartz.events
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.encoders.ATag
|
||||||
|
import com.vitorpamplona.quartz.encoders.HexKey
|
||||||
|
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class BlossomServersEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
override fun dTag() = FIXED_D_TAG
|
||||||
|
|
||||||
|
fun servers(): List<String> =
|
||||||
|
tags.mapNotNull {
|
||||||
|
if (it.size > 1 && it[0] == "server") {
|
||||||
|
it[1]
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 10063
|
||||||
|
const val FIXED_D_TAG = ""
|
||||||
|
const val ALT = "File servers used by the author"
|
||||||
|
|
||||||
|
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
|
||||||
|
|
||||||
|
fun createAddressTag(pubKey: HexKey): String = ATag.assembleATag(KIND, pubKey, FIXED_D_TAG)
|
||||||
|
|
||||||
|
fun createTagArray(servers: List<String>): Array<Array<String>> =
|
||||||
|
servers
|
||||||
|
.map {
|
||||||
|
arrayOf("server", it)
|
||||||
|
}.plusElement(arrayOf("alt", ALT))
|
||||||
|
.toTypedArray()
|
||||||
|
|
||||||
|
fun updateRelayList(
|
||||||
|
earlierVersion: BlossomServersEvent,
|
||||||
|
relays: List<String>,
|
||||||
|
signer: NostrSigner,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
onReady: (BlossomServersEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
val tags =
|
||||||
|
earlierVersion.tags
|
||||||
|
.filter { it[0] != "server" }
|
||||||
|
.plus(
|
||||||
|
relays.map {
|
||||||
|
arrayOf("server", it)
|
||||||
|
},
|
||||||
|
).toTypedArray()
|
||||||
|
|
||||||
|
signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createFromScratch(
|
||||||
|
relays: List<String>,
|
||||||
|
signer: NostrSigner,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
onReady: (BlossomServersEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
create(relays, signer, createdAt, onReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
servers: List<String>,
|
||||||
|
signer: NostrSigner,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
onReady: (BlossomServersEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
signer.sign(createdAt, KIND, createTagArray(servers), "", onReady)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -48,6 +48,8 @@ class EventFactory {
|
|||||||
BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
|
BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
|
BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig)
|
CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user