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