Adds support for Blossom media servers.

This commit is contained in:
Vitor Pamplona 2024-11-21 18:51:43 -05:00
parent c89c5eb4b0
commit 2c9e2de524
33 changed files with 1342 additions and 436 deletions

View File

@ -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))
}
}

View File

@ -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()

View File

@ -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 = {

View File

@ -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> =

View File

@ -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()

View File

@ -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)

View File

@ -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
}
}
}
}

View File

@ -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,
)

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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,
),
)
}

View File

@ -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 },

View File

@ -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()
}
},

View File

@ -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

View File

@ -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),

View File

@ -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()
}

View File

@ -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))

View File

@ -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,
) {

View File

@ -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()
}

View File

@ -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 },

View File

@ -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

View File

@ -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),
)

View File

@ -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

View File

@ -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) {

View File

@ -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 =

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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>

View File

@ -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(

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)