diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 856518bec..b05e3e462 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -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,36 +52,83 @@ import kotlin.random.Random @RunWith(AndroidJUnit4::class) class ImageUploadTesting { - private suspend fun testBase(server: Nip96MediaServers.ServerName) { - val serverInfo = - Nip96Retriever() - .loadInfo( - server.baseUrl, - false, - ) + 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) - val bytes = baos.toByteArray() - val inputStream = bytes.inputStream() + return baos.toByteArray() + } - val account = - Account( - AccountSettings(KeyPair()), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - ) + 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( + server.baseUrl, + false, + ) + + 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)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index fd601a395..3e5869560 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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>(PrefKeys.RELAYS) ?: emptySet() val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) - val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0] + val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 4dfd207d4..3b3d3fd0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -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 = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 16abcabee..023f44ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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> 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 = 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 { + 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) { if (!isWriteable()) return @@ -3688,6 +3752,31 @@ class Account( } } + fun sendBlossomServersList(servers: List) { + 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 = getAllPeopleLists(signer.pubKey) fun getAllPeopleLists(pubkey: HexKey): List = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 412762969..ebc5044c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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 = Constants.defaultRelays.toSet(), var localRelayServers: Set = setOf(), - var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], + var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultNotificationFollowList: MutableStateFlow = 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 09e236537..b58f87478 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt index 8590b1ff9..eea91d393 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt @@ -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 { + override fun key( + data: Uri, + options: Options, + ): String? = + if (data.scheme == "data") { + CryptoUtils.sha256(data.toString().toByteArray()).toHexKey() } else { null } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlossomUploader.kt new file mode 100644 index 000000000..ac1430b26 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlossomUploader.kt @@ -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 / 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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt index 43ea68a86..8d795917d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -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 { + object Factory : Fetcher.Factory { 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 { + override fun key( + data: Blurhash, + options: Options, + ): String = data.blurhash } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt index b0f7ffd69..be508d732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt @@ -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 = 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 - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt index 45a220a6f..273f411af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt @@ -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") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index c80b4331b..60d7a528e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -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, ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 4cd14c191..b1b935718 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 73af5310d..aba84e8fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -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() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 0fba89030..73b1819fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -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(null) - var selectedServer by mutableStateOf(null) + var selectedServer by mutableStateOf(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 5e3df001d..499359526 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index b36fa579d..2122e42bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index e2ee3ac07..608b1e459 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -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, - ) - - val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) - - if (url != null) { + 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, + ) + } + + 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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt similarity index 73% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index da14c5e95..83a84c1ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -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 = nip96ServersState, + editLabel = R.string.add_a_nip96_server, + emptyLabel = R.string.no_nip96_server_message, + onAddServer = { server -> + nip96ServersViewModel.addServer(server) + }, + onDeleteServer = { + nip96ServersViewModel.removeServer(serverUrl = it) + }, + ) + + item { + SettingsCategory( + stringRes(R.string.media_servers_blossom_section), + stringRes(R.string.media_servers_blossom_explainer), + ) + } + renderMediaServerList( - mediaServersState = mediaServersState, + mediaServersState = blossomServersState, + editLabel = R.string.add_a_blossom_server, + emptyLabel = R.string.no_blossom_server_message, onAddServer = { server -> - mediaServersViewModel.addServer(server) + blossomServersViewModel.addServer(server) }, onDeleteServer = { - mediaServersViewModel.removeServer(serverUrl = it) + blossomServersViewModel.removeServer(serverUrl = it) }, ) - Nip96MediaServers.DEFAULT.let { + 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, + mediaServersState: List, + 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, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt new file mode 100644 index 000000000..4fd5e81f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -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>(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) { + 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? = account.getBlossomServersList()?.servers() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt index ee853f4ac..ddbaf1b87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt @@ -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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt index 90bcbbb4b..3ef4aaf5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt @@ -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>(emptyList()) + private val _fileServers = MutableStateFlow>(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt new file mode 100644 index 000000000..0eeeb6654 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt @@ -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 = + 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), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index c55bf4396..45afda9c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index ae6a55063..308fa3a07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 46d58420f..2acdb1887 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -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 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() } 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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt index 5d6683576..f83c751a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt index 37d8c1025..05c9bbdd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt @@ -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, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5ce29951d..1b5a3fffd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -391,7 +391,10 @@ Media Servers Set your preferred media upload servers. - You have no custom media servers set. You can use Amethyst\'s list, or add one below ↓ + + You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓ + You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ + Built-in Media Servers Amethyst\'s default list. You can add them individually or add the list. Use Default List @@ -1046,7 +1049,14 @@ Loop Detected - The server detects an infinite loop while processing the request Network Authentication Required - The client must be authenticated to access the network + NIP-96 Servers + Add as many servers as you want. You can choose which one to use later when uploading your picture + + Blossom Servers + Add as many servers as you want. You can choose which one to use later when uploading your picture + Add a NIP-96 Server + Add a Blossom Server Delete all Are you sure you want to delete all drafts? " +%1$s" diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index f5b5030a3..0457c4dc1 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -43,58 +43,54 @@ import java.util.regex.Pattern import kotlin.coroutines.cancellation.CancellationException class RichTextParser { - fun createImageContent( + fun createMediaContent( fullUrl: String, eventTags: ImmutableListOfLists, description: String?, callbackUri: String? = null, - ): MediaUrlImage { + ): MediaUrlContent? { 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], - ) - } + val contentType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE] - fun createVideoContent( - fullUrl: String, - eventTags: ImmutableListOfLists, - 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], - ) - } + val isImage: Boolean + val isVideo: Boolean - fun parseMediaUrl( - fullUrl: String, - eventTags: ImmutableListOfLists, - 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) + 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( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomAuthorizationEvent.kt new file mode 100644 index 000000000..b05f88da6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomAuthorizationEvent.kt @@ -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>, + 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) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomServersEvent.kt new file mode 100644 index 000000000..02b1bb384 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BlossomServersEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun dTag() = FIXED_D_TAG + + fun servers(): List = + 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): Array> = + servers + .map { + arrayOf("server", it) + }.plusElement(arrayOf("alt", ALT)) + .toTypedArray() + + fun updateRelayList( + earlierVersion: BlossomServersEvent, + relays: List, + 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, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomServersEvent) -> Unit, + ) { + create(relays, signer, createdAt, onReady) + } + + fun create( + servers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlossomServersEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, createTagArray(servers), "", onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 44d65a222..40ac44adb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -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)