Adds support for Blossom media servers.

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

View File

@ -26,12 +26,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Retriever import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.toHexKey
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail import junit.framework.TestCase.fail
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -47,7 +52,69 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ImageUploadTesting { class ImageUploadTesting {
private suspend fun testBase(server: Nip96MediaServers.ServerName) { val account =
Account(
AccountSettings(KeyPair()),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
private suspend fun getBitmap(): ByteArray {
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
}
}
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
return baos.toByteArray()
}
private suspend fun testBase(server: ServerName) {
if (server.type == ServerType.NIP96) {
testNip96(server)
} else {
testBlossom(server)
}
}
private suspend fun testBlossom(server: ServerName) {
val paylod = getBitmap()
val initialHash = CryptoUtils.sha256(paylod).toHexKey()
val inputStream = paylod.inputStream()
val result =
BlossomUploader(account)
.uploadImage(
inputStream,
initialHash,
paylod.size,
"filename.png",
"image/png",
alt = null,
sensitiveContent = null,
server,
forceProxy = { false },
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
assertEquals("image/png", result.type)
assertEquals(paylod.size.toLong(), result.size)
assertEquals(initialHash, result.sha256)
assertEquals("${server.baseUrl}/$initialHash", result.url)
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(result.url!!, false)
?: run {
fail("${server.name}: Should not be null")
return
}
val downloadedHash = CryptoUtils.sha256(imageData).toHexKey()
assertEquals(initialHash, downloadedHash)
}
private suspend fun testNip96(server: ServerName) {
val serverInfo = val serverInfo =
Nip96Retriever() Nip96Retriever()
.loadInfo( .loadInfo(
@ -55,28 +122,13 @@ class ImageUploadTesting {
false, false,
) )
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888) val paylod = getBitmap()
for (x in 0 until bitmap.width) { val inputStream = paylod.inputStream()
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
}
}
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
val inputStream = bytes.inputStream()
val account =
Account(
AccountSettings(KeyPair()),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
val result = val result =
Nip96Uploader(account) Nip96Uploader(account)
.uploadImage( .uploadImage(
inputStream, inputStream,
bytes.size.toLong(), paylod.size.toLong(),
"image/png", "image/png",
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
@ -140,7 +192,7 @@ class ImageUploadTesting {
@Test @Test
fun runTestOnDefaultServers() = fun runTestOnDefaultServers() =
runBlocking { runBlocking {
Nip96MediaServers.DEFAULT.forEach { DEFAULT_MEDIA_SERVERS.forEach {
testBase(it) testBase(it)
} }
} }
@ -148,58 +200,76 @@ class ImageUploadTesting {
@Test() @Test()
fun testNostrCheck() = fun testNostrCheck() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me")) testBase(ServerName("nostrcheck.me", "https://nostrcheck.me", ServerType.NIP96))
} }
@Test() @Test()
@Ignore("Not Working anymore") @Ignore("Not Working anymore")
fun testNostrage() = fun testNostrage() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com")) testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
} }
@Test() @Test()
@Ignore("Not Working anymore") @Ignore("Not Working anymore")
fun testSove() = fun testSove() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent")) testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
} }
@Test() @Test()
fun testNostrBuild() = fun testNostrBuild() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build")) testBase(ServerName("nostr.build", "https://nostr.build", ServerType.NIP96))
} }
@Test() @Test()
@Ignore("Not Working anymore") @Ignore("Not Working anymore")
fun testSovbit() = fun testSovbit() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host")) testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
} }
@Test() @Test()
fun testVoidCat() = fun testVoidCat() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat")) testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96))
} }
@Test() @Test()
fun testNostrPic() = fun testNostrPic() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com")) testBase(ServerName("nostpic.com", "https://nostpic.com", ServerType.NIP96))
} }
@Test(expected = RuntimeException::class) @Test(expected = RuntimeException::class)
fun testSprovoostNl() = fun testSprovoostNl() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/")) testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
} }
@Test() @Test()
@Ignore("Not Working anymore") @Ignore("Not Working anymore")
fun testNostrOnch() = fun testNostrOnch() =
runBlocking { runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services")) testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
}
@Ignore("Changes sha256")
fun testPrimalBlossom() =
runBlocking {
testBase(ServerName("primal.net", "https://blossom.primal.net", ServerType.Blossom))
}
@Test()
fun testNostrCheckBlossom() =
runBlocking {
testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom))
}
@Ignore("Requires Payment")
fun testSatelliteBlossom() =
runBlocking {
testBase(ServerName("satellite", "https://cdn.satellite.earth", ServerType.Blossom))
} }
} }

View File

@ -37,8 +37,9 @@ import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Settings import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.amethyst.ui.tor.TorType
@ -521,7 +522,7 @@ object LocalPreferences {
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet() val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0] val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()

View File

@ -33,6 +33,7 @@ import coil3.util.DebugLogger
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Base64Fetcher import com.vitorpamplona.amethyst.service.Base64Fetcher
import com.vitorpamplona.amethyst.service.BlurHashFetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@ -134,6 +135,9 @@ class ServiceManager(
} }
add(SvgDecoder.Factory()) add(SvgDecoder.Factory())
add(Base64Fetcher.Factory) add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add( add(
OkHttpNetworkFetcherFactory( OkHttpNetworkFetcherFactory(
callFactory = { callFactory = {

View File

@ -36,6 +36,9 @@ import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.ammolite.relays.Client import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.ammolite.relays.Constants
@ -57,6 +60,8 @@ import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.events.BlossomServersEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
@ -138,6 +143,7 @@ import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.czeal.rfc3986.URIReference
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
@ -628,6 +634,19 @@ class Account(
) )
} }
val liveServerList: StateFlow<List<ServerName>> by lazy {
combine(getFileServersListFlow(), getBlossomServersListFlow()) { nip96, blossom ->
mergeServerList(nip96.note.event as? FileServersEvent, blossom.note.event as? BlossomServersEvent)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
runBlocking {
mergeServerList(getFileServersList(), getBlossomServersList())
},
)
}
suspend fun loadAndCombineFlows(listName: String): LiveFollowList? { suspend fun loadAndCombineFlows(listName: String): LiveFollowList? {
val flows = loadFlowsFor(listName) val flows = loadFlowsFor(listName)
return mapIntoFollowLists( return mapIntoFollowLists(
@ -1482,6 +1501,26 @@ class Account(
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady) HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
} }
fun createBlossomUploadAuth(
hash: HexKey,
alt: String,
onReady: (BlossomAuthorizationEvent) -> Unit,
) {
if (!isWriteable()) return
BlossomAuthorizationEvent.createUploadAuth(hash, alt, signer, onReady = onReady)
}
fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
onReady: (BlossomAuthorizationEvent) -> Unit,
) {
if (!isWriteable()) return
BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, onReady = onReady)
}
suspend fun boost(note: Note) { suspend fun boost(note: Note) {
if (!isWriteable()) return if (!isWriteable()) return
@ -3663,6 +3702,31 @@ class Account(
fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddressATag(userProfile().pubkeyHex)) fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddressATag(userProfile().pubkeyHex))
fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent
fun getBlossomServersListFlow(): StateFlow<NoteState> = getBlossomServersNote().flow().metadata.stateFlow
fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(BlossomServersEvent.createAddressATag(userProfile().pubkeyHex))
fun host(url: String): String =
try {
URIReference.parse(url).host.value
} catch (e: Exception) {
url
}
fun mergeServerList(
nip96: FileServersEvent?,
blossom: BlossomServersEvent?,
): List<ServerName> {
val nip96servers = nip96?.servers()?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList()
val blossomServers = blossom?.servers()?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList()
val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS }
return result + ServerName("NIP95", "", ServerType.NIP95)
}
fun sendFileServersList(servers: List<String>) { fun sendFileServersList(servers: List<String>) {
if (!isWriteable()) return if (!isWriteable()) return
@ -3688,6 +3752,31 @@ class Account(
} }
} }
fun sendBlossomServersList(servers: List<String>) {
if (!isWriteable()) return
val serverList = getBlossomServersList()
if (serverList != null && serverList.tags.isNotEmpty()) {
BlossomServersEvent.updateRelayList(
earlierVersion = serverList,
relays = servers,
signer = signer,
) {
Client.send(it)
LocalCache.justConsume(it, null)
}
} else {
BlossomServersEvent.createFromScratch(
relays = servers,
signer = signer,
) {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
}
fun getAllPeopleLists(): List<AddressableNote> = getAllPeopleLists(signer.pubKey) fun getAllPeopleLists(): List<AddressableNote> = getAllPeopleLists(signer.pubKey)
fun getAllPeopleLists(pubkey: HexKey): List<AddressableNote> = fun getAllPeopleLists(pubkey: HexKey): List<AddressableNote> =

View File

@ -23,7 +23,8 @@ package com.vitorpamplona.amethyst.model
import android.util.Log import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.ammolite.relays.Constants
@ -98,7 +99,7 @@ class AccountSettings(
var externalSignerPackageName: String? = null, var externalSignerPackageName: String? = null,
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(), var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var localRelayServers: Set<String> = setOf(), var localRelayServers: Set<String> = setOf(),
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS), val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS), val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS), val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
@ -202,7 +203,7 @@ class AccountSettings(
// file servers // file servers
// --- // ---
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { fun changeDefaultFileServer(server: ServerName) {
if (defaultFileServer != server) { if (defaultFileServer != server) {
defaultFileServer = server defaultFileServer = server
saveAccountSettings() saveAccountSettings()

View File

@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BaseAddressableEvent import com.vitorpamplona.quartz.events.BaseAddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.BlossomServersEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarEvent import com.vitorpamplona.quartz.events.CalendarEvent
@ -713,6 +714,13 @@ object LocalCache {
consumeBaseReplaceable(event, relay) consumeBaseReplaceable(event, relay)
} }
fun consume(
event: BlossomServersEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume( fun consume(
event: FileServersEvent, event: FileServersEvent,
relay: Relay?, relay: Relay?,
@ -2342,6 +2350,7 @@ object LocalCache {
is BadgeAwardEvent -> consume(event, relay) is BadgeAwardEvent -> consume(event, relay)
is BadgeDefinitionEvent -> consume(event, relay) is BadgeDefinitionEvent -> consume(event, relay)
is BadgeProfilesEvent -> consume(event) is BadgeProfilesEvent -> consume(event)
is BlossomServersEvent -> consume(event, relay)
is BookmarkListEvent -> consume(event) is BookmarkListEvent -> consume(event)
is CalendarEvent -> consume(event, relay) is CalendarEvent -> consume(event, relay)
is CalendarDateSlotEvent -> consume(event, relay) is CalendarDateSlotEvent -> consume(event, relay)

View File

@ -22,17 +22,20 @@ package com.vitorpamplona.amethyst.service
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import coil3.ImageLoader import coil3.ImageLoader
import coil3.Uri
import coil3.asImage import coil3.asImage
import coil3.decode.DataSource import coil3.decode.DataSource
import coil3.fetch.FetchResult import coil3.fetch.FetchResult
import coil3.fetch.Fetcher import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.Options import coil3.request.Options
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import java.util.Base64 import java.util.Base64
@Stable @Stable
@ -66,13 +69,24 @@ class Base64Fetcher(
data: Uri, data: Uri,
options: Options, options: Options,
imageLoader: ImageLoader, imageLoader: ImageLoader,
): Fetcher? { ): Fetcher? =
return if (base64contentPattern.matcher(data.toString()).find()) { if (data.scheme == "data") {
return Base64Fetcher(options, data) Base64Fetcher(options, data)
} else {
null
}
}
object BKeyer : Keyer<Uri> {
override fun key(
data: Uri,
options: Options,
): String? =
if (data.scheme == "data") {
CryptoUtils.sha256(data.toString().toByteArray()).toHexKey()
} else { } else {
null null
} }
}
} }
} }

View File

@ -0,0 +1,278 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Dimension
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.File
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
class BlossomUploader(
val account: Account?,
) {
fun Context.getFileName(uri: Uri): String? =
when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
else -> uri.path?.let(::File)?.name
}
private fun Context.getContentFileName(uri: Uri): String? =
runCatching {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
}
}.getOrNull()
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: ServerName,
contentResolver: ContentResolver,
forceProxy: (String) -> Boolean,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri)
val fileName = context.getFileName(uri)
val imageInputStreamForHash = contentResolver.openInputStream(uri)
val payload =
imageInputStreamForHash?.use {
it.readBytes()
}
checkNotNull(payload) { "Can't open the image input stream" }
val hash = CryptoUtils.sha256(payload).toHexKey()
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" }
return uploadImage(
imageInputStream,
hash,
payload.size,
fileName,
myContentType,
alt,
sensitiveContent,
server,
forceProxy,
context,
)
}
suspend fun uploadImage(
inputStream: InputStream,
hash: HexKey,
length: Int,
fileName: String?,
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: ServerName,
forceProxy: (String) -> Boolean,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val fileName = randomChars()
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val apiUrl = server.baseUrl.removeSuffix("/") + "/upload"
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
val requestBuilder = Request.Builder()
val requestBody: RequestBody =
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length.toLong()
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
authUploadHeader(
hash,
alt?.let { "Uploading $it" } ?: "Uploading $fileName",
)?.let {
requestBuilder.addHeader("Authorization", it)
}
contentType?.let { requestBuilder.addHeader("Content-Type", it) }
requestBuilder
.addHeader("Content-Length", length.toString())
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(apiUrl)
.put(requestBody)
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseResults(str)
return result
}
} else {
val errorMessage = response.headers.get("X-Reason")
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (errorMessage != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
} else if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
}
}
}
}
suspend fun delete(
hash: String,
contentType: String?,
server: ServerName,
forceProxy: (String) -> Boolean,
context: Context,
): Boolean {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val apiUrl = server.baseUrl
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
val requestBuilder = Request.Builder()
authDeleteHeader(
hash,
"Deleting $hash",
)?.let { requestBuilder.addHeader("Authorization", it) }
val request =
requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(apiUrl.removeSuffix("/") + "/$hash.$extension")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
return true
} else {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
}
}
}
}
suspend fun authUploadHeader(
hash: String,
alt: String,
): String? {
val myAccount = account ?: return null
return tryAndWait { continuation ->
myAccount.createBlossomUploadAuth(hash, alt) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
continuation.resume("Nostr $encodedNIP98Event")
}
}
}
suspend fun authDeleteHeader(
hash: String,
alt: String,
): String? {
val myAccount = account ?: return null
return tryAndWait { continuation ->
myAccount.createBlossomDeleteAuth(hash, alt) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
continuation.resume("Nostr $encodedNIP98Event")
}
}
}
private fun parseResults(body: String): MediaUploadResult {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, MediaUploadResult::class.java)
}
}
data class MediaUploadResult(
// A publicly accessible URL to the BUD-01 GET /<sha256> endpoint (optionally with a file extension)
val url: String?,
// The sha256 hash of the blob
val sha256: HexKey? = null,
// The size of the blob in bytes
val size: Long? = null,
// (optional) The MIME type of the blob
val type: String? = null,
// upload time
val uploaded: Long? = null,
// dimensions
val dimension: Dimension? = null,
// magnet link
val magnet: String? = null,
val infohash: String? = null,
// ipfs link
val ipfs: String? = null,
)

View File

@ -20,8 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import coil3.ImageLoader import coil3.ImageLoader
import coil3.asImage import coil3.asImage
@ -29,23 +27,25 @@ import coil3.decode.DataSource
import coil3.fetch.FetchResult import coil3.fetch.FetchResult
import coil3.fetch.Fetcher import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult import coil3.fetch.ImageFetchResult
import coil3.request.ImageRequest import coil3.key.Keyer
import coil3.request.Options import coil3.request.Options
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
import java.net.URLDecoder
import java.net.URLEncoder class Blurhash(
val blurhash: String,
)
@Stable @Stable
class BlurHashFetcher( class BlurHashFetcher(
private val options: Options, private val options: Options,
private val data: Uri, private val data: Blurhash,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
checkNotInMainThread() checkNotInMainThread()
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8") val hash = data.blurhash
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data") val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data")
return ImageFetchResult( return ImageFetchResult(
image = bitmap.asImage(true), image = bitmap.asImage(true),
@ -54,26 +54,18 @@ class BlurHashFetcher(
) )
} }
object Factory : Fetcher.Factory<Uri> { object Factory : Fetcher.Factory<Blurhash> {
override fun create( override fun create(
data: Uri, data: Blurhash,
options: Options, options: Options,
imageLoader: ImageLoader, imageLoader: ImageLoader,
): Fetcher = BlurHashFetcher(options, data) ): Fetcher = BlurHashFetcher(options, data)
} }
}
object BlurHashRequester { object BKeyer : Keyer<Blurhash> {
fun imageRequest( override fun key(
context: Context, data: Blurhash,
message: String, options: Options,
): ImageRequest { ): String = data.blurhash
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.build()
} }
} }

View File

@ -31,20 +31,6 @@ import java.net.URI
import java.net.URL import java.net.URL
object Nip96MediaServers { object Nip96MediaServers {
val DEFAULT =
listOf(
ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("NostPic", "https://nostpic.com"),
ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat"),
)
data class ServerName(
val name: String,
val baseUrl: String,
)
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf() val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
suspend fun load( suspend fun load(
@ -136,23 +122,23 @@ class Nip96Retriever {
} }
} }
} }
fun makeAbsoluteIfRelativeUrl(
baseUrl: String,
potentialyRelativeUrl: String,
): String =
try {
val apiUrl = URI(potentialyRelativeUrl)
if (apiUrl.isAbsolute) {
potentialyRelativeUrl
} else {
URL(URL(baseUrl), potentialyRelativeUrl).toString()
}
} catch (e: Exception) {
potentialyRelativeUrl
}
} }
typealias PlanName = String typealias PlanName = String
typealias MimeType = String typealias MimeType = String
fun makeAbsoluteIfRelativeUrl(
baseUrl: String,
potentialyRelativeUrl: String,
): String =
try {
val apiUrl = URI(potentialyRelativeUrl)
if (apiUrl.isAbsolute) {
potentialyRelativeUrl
} else {
URL(URL(baseUrl), potentialyRelativeUrl).toString()
}
} catch (e: Exception) {
potentialyRelativeUrl
}

View File

@ -33,8 +33,10 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.Dimension
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
@ -59,12 +61,12 @@ class Nip96Uploader(
size: Long?, size: Long?,
alt: String?, alt: String?,
sensitiveContent: String?, sensitiveContent: String?,
server: Nip96MediaServers.ServerName, server: ServerName,
contentResolver: ContentResolver, contentResolver: ContentResolver,
forceProxy: (String) -> Boolean, forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
context: Context, context: Context,
): PartialEvent { ): MediaUploadResult {
val serverInfo = val serverInfo =
Nip96Retriever() Nip96Retriever()
.loadInfo( .loadInfo(
@ -97,7 +99,7 @@ class Nip96Uploader(
forceProxy: (String) -> Boolean, forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
context: Context, context: Context,
): PartialEvent { ): MediaUploadResult {
checkNotInMainThread() checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri) val myContentType = contentType ?: contentResolver.getType(uri)
@ -137,7 +139,7 @@ class Nip96Uploader(
forceProxy: (String) -> Boolean, forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
context: Context, context: Context,
): PartialEvent { ): MediaUploadResult {
checkNotInMainThread() checkNotInMainThread()
val fileName = randomChars() val fileName = randomChars()
@ -189,7 +191,7 @@ class Nip96Uploader(
if (!result.processingUrl.isNullOrBlank()) { if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, forceProxy, onProgress) return waitProcessing(result, server, forceProxy, onProgress)
} else if (result.status == "success" && result.nip94Event != null) { } else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event return convertToMediaResult(result.nip94Event)
} else { } else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message)) throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
} }
@ -223,6 +225,40 @@ class Nip96Uploader(
} }
} }
fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult {
// Images don't seem to be ready immediately after upload
val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
?.let { Dimension.parse(it) }
val magnet =
nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
return MediaUploadResult(
url = imageUrl,
type = remoteMimeType,
sha256 = originalHash,
dimension = dim,
magnet = magnet,
)
}
suspend fun delete( suspend fun delete(
hash: String, hash: String,
contentType: String?, contentType: String?,
@ -269,7 +305,7 @@ class Nip96Uploader(
server: Nip96Retriever.ServerInfo, server: Nip96Retriever.ServerInfo,
forceProxy: (String) -> Boolean, forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
): PartialEvent { ): MediaUploadResult {
var currentResult = result var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
@ -296,7 +332,7 @@ class Nip96Uploader(
val nip94 = currentResult.nip94Event val nip94 = currentResult.nip94Event
if (nip94 != null) { if (nip94 != null) {
return nip94 return convertToMediaResult(nip94)
} else { } else {
throw RuntimeException("Error waiting for processing. Final result is unavailable") throw RuntimeException("Error waiting for processing. Final result is unavailable")
} }

View File

@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppSpecificDataEvent import com.vitorpamplona.quartz.events.AppSpecificDataEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BlossomServersEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarRSVPEvent import com.vitorpamplona.quartz.events.CalendarRSVPEvent
@ -94,10 +95,11 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
ChatMessageRelayListEvent.KIND, ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND, SearchRelayListEvent.KIND,
FileServersEvent.KIND, FileServersEvent.KIND,
BlossomServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND, PrivateOutboxRelayListEvent.KIND,
), ),
authors = listOf(account.userProfile().pubkeyHex), authors = listOf(account.userProfile().pubkeyHex),
limit = 10, limit = 20,
), ),
) )
@ -116,11 +118,12 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
ChatMessageRelayListEvent.KIND, ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND, SearchRelayListEvent.KIND,
FileServersEvent.KIND, FileServersEvent.KIND,
BlossomServersEvent.KIND,
MuteListEvent.KIND, MuteListEvent.KIND,
PeopleListEvent.KIND, PeopleListEvent.KIND,
), ),
authors = otherAuthors, authors = otherAuthors,
limit = otherAuthors.size * 10, limit = otherAuthors.size * 20,
), ),
) )
} }

View File

@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
@ -336,8 +337,8 @@ fun EditPostView(
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
if (!server.isNip95) { if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server.server) accountViewModel.account.settings.changeDefaultFileServer(server)
} }
}, },
onCancel = { postViewModel.contentToAddUrl = null }, onCancel = { postViewModel.contentToAddUrl = null },

View File

@ -38,14 +38,17 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
@ -151,7 +154,7 @@ open class EditPostViewModel : ViewModel() {
sensitiveContent: Boolean, sensitiveContent: Boolean,
mediaQuality: Int, mediaQuality: Int,
isPrivate: Boolean = false, isPrivate: Boolean = false,
server: ServerOption, server: ServerName,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
) { ) {
@ -168,7 +171,7 @@ open class EditPostViewModel : ViewModel() {
contentType, contentType,
context.applicationContext, context.applicationContext,
onReady = { fileUri, contentType, size -> onReady = { fileUri, contentType, size ->
if (server.isNip95) { if (server.type == ServerType.NIP95) {
contentResolver.openInputStream(fileUri)?.use { contentResolver.openInputStream(fileUri)?.use {
createNIP95Record( createNIP95Record(
it.readBytes(), it.readBytes(),
@ -181,7 +184,7 @@ open class EditPostViewModel : ViewModel() {
context, context,
) )
} }
} else { } else if (server.type == ServerType.NIP96) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val result = val result =
@ -192,13 +195,52 @@ open class EditPostViewModel : ViewModel() {
size = size, size = size,
alt = alt, alt = alt,
sensitiveContent = if (sensitiveContent) "" else null, sensitiveContent = if (sensitiveContent) "" else null,
server = server.server, server = server,
contentResolver = contentResolver, contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = {}, onProgress = {},
context = context, context = context,
) )
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
} else {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record( createNIP94Record(
uploadingResult = result, uploadingResult = result,
localContentType = contentType, localContentType = contentType,
@ -317,7 +359,7 @@ open class EditPostViewModel : ViewModel() {
contentToAddUrl == null contentToAddUrl == null
suspend fun createNIP94Record( suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent, uploadingResult: MediaUploadResult,
localContentType: String?, localContentType: String?,
alt: String?, alt: String?,
sensitiveContent: Boolean, sensitiveContent: Boolean,
@ -325,31 +367,7 @@ open class EditPostViewModel : ViewModel() {
onError: (String) -> Unit = {}, onError: (String) -> Unit = {},
context: Context, context: Context,
) { ) {
// Images don't seem to be ready immediately after upload if (uploadingResult.url.isNullOrBlank()) {
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
?.let { Dimension.parse(it) }
val magnet =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server") Log.e("ImageDownload", "Couldn't download image from server")
cancel() cancel()
isUploadingImage = false isUploadingImage = false
@ -358,16 +376,16 @@ open class EditPostViewModel : ViewModel() {
} }
FileHeader.prepare( FileHeader.prepare(
fileUrl = imageUrl, fileUrl = uploadingResult.url,
mimeType = remoteMimeType ?: localContentType, mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = dim, dimPrecomputed = uploadingResult.dimension,
forceProxy = forceProxy(imageUrl), forceProxy = forceProxy(uploadingResult.url),
onReady = { header: FileHeader -> onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
isUploadingImage = false isUploadingImage = false
nip94attachments = nip94attachments + event nip94attachments = nip94attachments + event
message = message.insertUrlAtCursor(imageUrl) message = message.insertUrlAtCursor(uploadingResult.url)
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
} }
}, },

View File

@ -31,22 +31,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.events.Dimension
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class ServerOption(
val server: Nip96MediaServers.ServerName,
val isNip95: Boolean,
)
@Stable @Stable
open class NewMediaModel : ViewModel() { open class NewMediaModel : ViewModel() {
var account: Account? = null var account: Account? = null
@ -54,7 +52,7 @@ open class NewMediaModel : ViewModel() {
var isUploadingImage by mutableStateOf(false) var isUploadingImage by mutableStateOf(false)
var mediaType by mutableStateOf<String?>(null) var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServerOption?>(null) var selectedServer by mutableStateOf<ServerName?>(null)
var alt by mutableStateOf("") var alt by mutableStateOf("")
var sensitiveContent by mutableStateOf(false) var sensitiveContent by mutableStateOf(false)
@ -74,7 +72,7 @@ open class NewMediaModel : ViewModel() {
this.account = account this.account = account
this.galleryUri = uri this.galleryUri = uri
this.mediaType = contentType this.mediaType = contentType
this.selectedServer = ServerOption(defaultServer(), false) this.selectedServer = defaultServer()
} }
fun upload( fun upload(
@ -100,7 +98,7 @@ open class NewMediaModel : ViewModel() {
contentType, contentType,
context.applicationContext, context.applicationContext,
onReady = { fileUri, contentType, size -> onReady = { fileUri, contentType, size ->
if (serverToUse.isNip95) { if (serverToUse.type == ServerType.NIP95) {
uploadingPercentage.value = 0.2f uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading" uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use { contentResolver.openInputStream(fileUri)?.use {
@ -122,7 +120,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = null uploadingDescription.value = null
} }
} }
} else { } else if (serverToUse.type == ServerType.NIP96) {
uploadingPercentage.value = 0.2f uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading" uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@ -135,7 +133,7 @@ open class NewMediaModel : ViewModel() {
size = size, size = size,
alt = alt, alt = alt,
sensitiveContent = if (sensitiveContent) "" else null, sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse.server, server = serverToUse,
contentResolver = contentResolver, contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = { percent: Float -> onProgress = { percent: Float ->
@ -144,6 +142,43 @@ open class NewMediaModel : ViewModel() {
context = context, context = context,
) )
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = onError,
context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
}
}
} else if (serverToUse.type == ServerType.Blossom) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record( createNIP94Record(
uploadingResult = result, uploadingResult = result,
localContentType = contentType, localContentType = contentType,
@ -183,13 +218,13 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.0f uploadingPercentage.value = 0.0f
alt = "" alt = ""
selectedServer = ServerOption(defaultServer(), false) selectedServer = defaultServer()
} }
fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null
suspend fun createNIP94Record( suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent, uploadingResult: MediaUploadResult,
localContentType: String?, localContentType: String?,
alt: String, alt: String,
sensitiveContent: Boolean, sensitiveContent: Boolean,
@ -202,30 +237,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = "Server Processing" uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload // Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) if (uploadingResult.url.isNullOrBlank()) {
val remoteMimeType =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
?.let { Dimension.parse(it) }
val magnet =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server") Log.e("ImageDownload", "Couldn't download image from server")
cancel() cancel()
uploadingPercentage.value = 0.00f uploadingPercentage.value = 0.00f
@ -238,7 +250,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = "Downloading" uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f uploadingPercentage.value = 0.60f
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl, forceProxy(imageUrl)) val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadingResult.url, forceProxy(uploadingResult.url))
if (imageData != null) { if (imageData != null) {
uploadingPercentage.value = 0.80f uploadingPercentage.value = 0.80f
@ -246,18 +258,18 @@ open class NewMediaModel : ViewModel() {
FileHeader.prepare( FileHeader.prepare(
data = imageData, data = imageData,
mimeType = remoteMimeType ?: localContentType, mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = dim, dimPrecomputed = uploadingResult.dimension,
onReady = { onReady = {
uploadingPercentage.value = 0.90f uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending" uploadingDescription.value = "Sending"
account?.sendHeader( account?.sendHeader(
imageUrl, uploadingResult.url,
magnet, uploadingResult.magnet,
it, it,
alt, alt,
sensitiveContent, sensitiveContent,
originalHash, uploadingResult.sha256,
relayList, relayList,
) { ) {
uploadingPercentage.value = 1.00f uploadingPercentage.value = 1.00f
@ -340,7 +352,7 @@ open class NewMediaModel : ViewModel() {
fun isVideo() = mediaType?.startsWith("video") fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
fun onceUploaded(onceUploaded: () -> Unit) { fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded this.onceUploaded = onceUploaded

View File

@ -55,8 +55,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -75,7 +75,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
@ -89,7 +89,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.FileServersEvent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -163,8 +162,8 @@ fun NewMediaView(
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it) accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
} }
postViewModel.selectedServer?.let { postViewModel.selectedServer?.let {
if (!it.isNip95) { if (it.type != ServerType.NIP95) {
account.settings.changeDefaultFileServer(it.server) account.settings.changeDefaultFileServer(it)
} }
} }
}, },
@ -282,39 +281,21 @@ fun ImageVideoPost(
postViewModel: NewMediaModel, postViewModel: NewMediaModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val listOfNip96ServersNote = val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
accountViewModel.account val fileServers by accountViewModel.account.liveServerList.collectAsState()
.getFileServersNote()
.live()
.metadata
.observeAsState()
val fileServers =
(
(listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map {
ServerOption(
Nip96MediaServers.ServerName(
it,
it,
),
false,
)
} ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) }
) +
listOf(
ServerOption(
Nip96MediaServers.ServerName(
"NIP95",
stringRes(id = R.string.upload_server_relays_nip95),
),
true,
),
)
val fileServerOptions = val fileServerOptions =
remember { remember(fileServers) {
fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() fileServers
.map {
if (it.type == ServerType.NIP95) {
TitleExplainer(it.name, nip95description)
} else {
TitleExplainer(it.name, it.baseUrl)
}
}.toImmutableList()
} }
val resolver = LocalContext.current.contentResolver val resolver = LocalContext.current.contentResolver
Row( Row(
@ -381,10 +362,9 @@ fun ImageVideoPost(
label = stringRes(id = R.string.file_server), label = stringRes(id = R.string.file_server),
placeholder = placeholder =
fileServers fileServers
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer } .firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
?.server
?.name ?.name
?: fileServers[0].server.name, ?: fileServers[0].name,
options = fileServerOptions, options = fileServerOptions,
onSelect = { postViewModel.selectedServer = fileServers[it] }, onSelect = { postViewModel.selectedServer = fileServers[it] },
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f), modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),

View File

@ -42,10 +42,14 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@ -61,7 +65,6 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileHeaderEvent
@ -869,7 +872,7 @@ open class NewPostViewModel : ViewModel() {
sensitiveContent: Boolean, sensitiveContent: Boolean,
mediaQuality: Int, mediaQuality: Int,
isPrivate: Boolean = false, isPrivate: Boolean = false,
server: ServerOption, server: ServerName,
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
context: Context, context: Context,
) { ) {
@ -886,7 +889,7 @@ open class NewPostViewModel : ViewModel() {
contentType, contentType,
context.applicationContext, context.applicationContext,
onReady = { fileUri, contentType, size -> onReady = { fileUri, contentType, size ->
if (server.isNip95) { if (server.type == ServerType.NIP95) {
contentResolver.openInputStream(fileUri)?.use { contentResolver.openInputStream(fileUri)?.use {
createNIP95Record( createNIP95Record(
it.readBytes(), it.readBytes(),
@ -899,7 +902,7 @@ open class NewPostViewModel : ViewModel() {
context, context,
) )
} }
} else { } else if (server.type == ServerType.NIP96) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val result = val result =
@ -910,13 +913,52 @@ open class NewPostViewModel : ViewModel() {
size = size, size = size,
alt = alt, alt = alt,
sensitiveContent = if (sensitiveContent) "" else null, sensitiveContent = if (sensitiveContent) "" else null,
server = server.server, server = server,
contentResolver = contentResolver, contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false }, forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = {}, onProgress = {},
context = context, context = context,
) )
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
} else if (server.type == ServerType.Blossom) {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record( createNIP94Record(
uploadingResult = result, uploadingResult = result,
localContentType = contentType, localContentType = contentType,
@ -1182,7 +1224,7 @@ open class NewPostViewModel : ViewModel() {
contentToAddUrl == null contentToAddUrl == null
suspend fun createNIP94Record( suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent, uploadingResult: MediaUploadResult,
localContentType: String?, localContentType: String?,
alt: String?, alt: String?,
sensitiveContent: Boolean, sensitiveContent: Boolean,
@ -1190,31 +1232,7 @@ open class NewPostViewModel : ViewModel() {
onError: (message: String) -> Unit, onError: (message: String) -> Unit,
context: Context, context: Context,
) { ) {
// Images don't seem to be ready immediately after upload if (uploadingResult.url.isNullOrBlank()) {
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
?.let { Dimension.parse(it) }
val magnet =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server") Log.e("ImageDownload", "Couldn't download image from server")
cancel() cancel()
isUploadingImage = false isUploadingImage = false
@ -1223,16 +1241,16 @@ open class NewPostViewModel : ViewModel() {
} }
FileHeader.prepare( FileHeader.prepare(
fileUrl = imageUrl, fileUrl = uploadingResult.url,
mimeType = remoteMimeType ?: localContentType, mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = dim, dimPrecomputed = uploadingResult.dimension,
forceProxy = forceProxy(imageUrl), forceProxy = forceProxy(uploadingResult.url),
onReady = { header: FileHeader -> onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
isUploadingImage = false isUploadingImage = false
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
message = message.insertUrlAtCursor(imageUrl) message = message.insertUrlAtCursor(uploadingResult.url)
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
saveDraft() saveDraft()
} }

View File

@ -29,7 +29,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.CompressorQuality import com.vitorpamplona.amethyst.ui.components.CompressorQuality
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@ -181,25 +183,38 @@ class NewUserMetadataViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val result = val result =
Nip96Uploader(account) if (account.settings.defaultFileServer.type == ServerType.NIP96) {
.uploadImage( Nip96Uploader(account)
uri = fileUri, .uploadImage(
contentType = contentType, uri = fileUri,
size = size, contentType = contentType,
alt = null, size = size,
sensitiveContent = null, alt = null,
server = account.settings.defaultFileServer, sensitiveContent = null,
contentResolver = contentResolver, server = account.settings.defaultFileServer,
forceProxy = account::shouldUseTorForNIP96, contentResolver = contentResolver,
onProgress = {}, forceProxy = account::shouldUseTorForNIP96,
context = context, onProgress = {},
) context = context,
)
} else {
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = null,
sensitiveContent = null,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
forceProxy = account::shouldUseTorForNIP96,
context = context,
)
}
val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) if (result.url != null) {
if (url != null) {
onUploading(false) onUploading(false)
onUploaded(url) onUploaded(result.url)
} else { } else {
onUploading(false) onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))

View File

@ -54,13 +54,14 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@ -73,11 +74,15 @@ fun MediaServersListView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val mediaServersViewModel: MediaServersViewModel = viewModel() val nip96ServersViewModel: NIP96ServersViewModel = viewModel()
val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle() val nip96ServersState by nip96ServersViewModel.fileServers.collectAsStateWithLifecycle()
val blossomServersViewModel: BlossomServersViewModel = viewModel()
val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle()
LaunchedEffect(key1 = Unit) { LaunchedEffect(key1 = Unit) {
mediaServersViewModel.load(accountViewModel.account) nip96ServersViewModel.load(accountViewModel.account)
blossomServersViewModel.load(accountViewModel.account)
} }
Dialog( Dialog(
@ -102,7 +107,8 @@ fun MediaServersListView(
navigationIcon = { navigationIcon = {
CloseButton( CloseButton(
onPress = { onPress = {
mediaServersViewModel.refresh() nip96ServersViewModel.refresh()
blossomServersViewModel.refresh()
onClose() onClose()
}, },
) )
@ -110,7 +116,8 @@ fun MediaServersListView(
actions = { actions = {
SaveButton( SaveButton(
onPost = { onPost = {
mediaServersViewModel.saveFileServers() nip96ServersViewModel.saveFileServers()
blossomServersViewModel.saveFileServers()
onClose() onClose()
}, },
isActive = true, isActive = true,
@ -148,17 +155,46 @@ fun MediaServersListView(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = FeedPadding, contentPadding = FeedPadding,
) { ) {
item {
SettingsCategory(
stringRes(R.string.media_servers_nip96_section),
stringRes(R.string.media_servers_nip96_explainer),
Modifier.padding(bottom = 8.dp),
)
}
renderMediaServerList( renderMediaServerList(
mediaServersState = mediaServersState, mediaServersState = nip96ServersState,
editLabel = R.string.add_a_nip96_server,
emptyLabel = R.string.no_nip96_server_message,
onAddServer = { server -> onAddServer = { server ->
mediaServersViewModel.addServer(server) nip96ServersViewModel.addServer(server)
}, },
onDeleteServer = { onDeleteServer = {
mediaServersViewModel.removeServer(serverUrl = it) nip96ServersViewModel.removeServer(serverUrl = it)
}, },
) )
Nip96MediaServers.DEFAULT.let { item {
SettingsCategory(
stringRes(R.string.media_servers_blossom_section),
stringRes(R.string.media_servers_blossom_explainer),
)
}
renderMediaServerList(
mediaServersState = blossomServersState,
editLabel = R.string.add_a_blossom_server,
emptyLabel = R.string.no_blossom_server_message,
onAddServer = { server ->
blossomServersViewModel.addServer(server)
},
onDeleteServer = {
blossomServersViewModel.removeServer(serverUrl = it)
},
)
DEFAULT_MEDIA_SERVERS.let {
item { item {
SettingsCategoryWithButton( SettingsCategoryWithButton(
title = stringRes(id = R.string.built_in_media_servers_title), title = stringRes(id = R.string.built_in_media_servers_title),
@ -166,7 +202,13 @@ fun MediaServersListView(
action = { action = {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
mediaServersViewModel.addServerList(it.map { s -> s.baseUrl }) nip96ServersViewModel.addServerList(
it.mapNotNull { s -> if (s.type == ServerType.NIP96) s.baseUrl else null },
)
blossomServersViewModel.addServerList(
it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null },
)
}, },
) { ) {
Text(text = stringRes(id = R.string.use_default_servers)) Text(text = stringRes(id = R.string.use_default_servers))
@ -176,7 +218,7 @@ fun MediaServersListView(
} }
itemsIndexed( itemsIndexed(
it, it,
key = { index: Int, server: Nip96MediaServers.ServerName -> key = { index: Int, server: ServerName ->
server.baseUrl server.baseUrl
}, },
) { index, server -> ) { index, server ->
@ -184,11 +226,19 @@ fun MediaServersListView(
serverEntry = server, serverEntry = server,
isAmethystDefault = true, isAmethystDefault = true,
onAddOrDelete = { serverUrl -> onAddOrDelete = { serverUrl ->
mediaServersViewModel.addServer(serverUrl) if (server.type == ServerType.NIP96) {
nip96ServersViewModel.addServer(serverUrl)
} else if (server.type == ServerType.Blossom) {
blossomServersViewModel.addServer(serverUrl)
}
}, },
) )
} }
} }
item {
Spacer(DoubleHorzSpacer)
}
} }
} }
} }
@ -196,21 +246,23 @@ fun MediaServersListView(
} }
fun LazyListScope.renderMediaServerList( fun LazyListScope.renderMediaServerList(
mediaServersState: List<Nip96MediaServers.ServerName>, mediaServersState: List<ServerName>,
editLabel: Int,
emptyLabel: Int,
onAddServer: (String) -> Unit, onAddServer: (String) -> Unit,
onDeleteServer: (String) -> Unit, onDeleteServer: (String) -> Unit,
) { ) {
if (mediaServersState.isEmpty()) { if (mediaServersState.isEmpty()) {
item { item {
Text( Text(
text = stringRes(id = R.string.no_media_server_message), text = stringRes(id = emptyLabel),
modifier = DoubleVertPadding, modifier = DoubleVertPadding,
) )
} }
} else { } else {
itemsIndexed( itemsIndexed(
mediaServersState, mediaServersState,
key = { index: Int, server: Nip96MediaServers.ServerName -> key = { index: Int, server: ServerName ->
server.baseUrl server.baseUrl
}, },
) { index, entry -> ) { index, entry ->
@ -225,7 +277,7 @@ fun LazyListScope.renderMediaServerList(
item { item {
Spacer(modifier = StdVertSpacer) Spacer(modifier = StdVertSpacer)
MediaServerEditField { MediaServerEditField(editLabel) {
onAddServer(it) onAddServer(it)
} }
} }
@ -234,7 +286,7 @@ fun LazyListScope.renderMediaServerList(
@Composable @Composable
fun MediaServerEntry( fun MediaServerEntry(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
serverEntry: Nip96MediaServers.ServerName, serverEntry: ServerName,
isAmethystDefault: Boolean = false, isAmethystDefault: Boolean = false,
onAddOrDelete: (serverUrl: String) -> Unit, onAddOrDelete: (serverUrl: String) -> Unit,
) { ) {

View File

@ -0,0 +1,131 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.czeal.rfc3986.URIReference
class BlossomServersViewModel : ViewModel() {
lateinit var account: Account
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
val fileServers = _fileServers.asStateFlow()
private var isModified = false
fun load(account: Account) {
this.account = account
refresh()
}
fun refresh() {
isModified = false
_fileServers.update {
val obtainedFileServers = obtainFileServers() ?: emptyList()
obtainedFileServers.mapNotNull { serverUrl ->
try {
ServerName(
URIReference.parse(serverUrl).host.value,
serverUrl,
ServerType.Blossom,
)
} catch (e: Exception) {
Log.d("MediaServersViewModel", "Invalid URL in Blossom server list")
null
}
}
}
}
fun addServerList(serverList: List<String>) {
serverList.forEach { serverUrl ->
addServer(serverUrl)
}
}
fun addServer(serverUrl: String) {
val normalizedUrl =
try {
URIReference.parse(serverUrl.trim()).normalize().toString()
} catch (e: Exception) {
serverUrl
}
val serverNameReference =
try {
URIReference.parse(normalizedUrl).host.value
} catch (e: Exception) {
normalizedUrl
}
val serverRef =
ServerName(
serverNameReference,
normalizedUrl,
ServerType.Blossom,
)
if (_fileServers.value.contains(serverRef)) {
return
} else {
_fileServers.update {
it.plus(serverRef)
}
}
isModified = true
}
fun removeServer(
name: String = "",
serverUrl: String,
) {
viewModelScope.launch {
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
_fileServers.update {
it.minus(
ServerName(serverName, serverUrl, ServerType.Blossom),
)
}
isModified = true
}
}
fun removeAllServers() {
_fileServers.update { emptyList() }
isModified = true
}
fun saveFileServers() {
if (isModified) {
viewModelScope.launch(Dispatchers.IO) {
val serverList = _fileServers.value.map { it.baseUrl }
account.sendBlossomServersList(serverList)
refresh()
}
}
}
private fun obtainFileServers(): List<String>? = account.getBlossomServersList()?.servers()
}

View File

@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.encoders.HttpUrlFormatter
@Composable @Composable
fun MediaServerEditField( fun MediaServerEditField(
label: Int = R.string.add_a_nip96_server,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onAddServer: (String) -> Unit, onAddServer: (String) -> Unit,
) { ) {
@ -63,7 +64,7 @@ fun MediaServerEditField(
), ),
) { ) {
OutlinedTextField( OutlinedTextField(
label = { Text(text = stringRes(R.string.add_a_nip96_server)) }, label = { Text(text = stringRes(label)) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = url, value = url,
onValueChange = { url = it }, onValueChange = { url = it },

View File

@ -24,7 +24,6 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -32,10 +31,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.czeal.rfc3986.URIReference import org.czeal.rfc3986.URIReference
class MediaServersViewModel : ViewModel() { class NIP96ServersViewModel : ViewModel() {
lateinit var account: Account lateinit var account: Account
private val _fileServers = MutableStateFlow<List<Nip96MediaServers.ServerName>>(emptyList()) private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
val fileServers = _fileServers.asStateFlow() val fileServers = _fileServers.asStateFlow()
private var isModified = false private var isModified = false
@ -50,11 +49,11 @@ class MediaServersViewModel : ViewModel() {
val obtainedFileServers = obtainFileServers() ?: emptyList() val obtainedFileServers = obtainFileServers() ?: emptyList()
obtainedFileServers.mapNotNull { serverUrl -> obtainedFileServers.mapNotNull { serverUrl ->
try { try {
Nip96MediaServers ServerName(
.ServerName( URIReference.parse(serverUrl).host.value,
URIReference.parse(serverUrl).host.value, serverUrl,
serverUrl, ServerType.NIP96,
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list") Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list")
null null
@ -82,7 +81,7 @@ class MediaServersViewModel : ViewModel() {
} catch (e: Exception) { } catch (e: Exception) {
normalizedUrl normalizedUrl
} }
val serverRef = Nip96MediaServers.ServerName(serverNameReference, normalizedUrl) val serverRef = ServerName(serverNameReference, normalizedUrl, ServerType.NIP96)
if (_fileServers.value.contains(serverRef)) { if (_fileServers.value.contains(serverRef)) {
return return
} else { } else {
@ -101,7 +100,7 @@ class MediaServersViewModel : ViewModel() {
val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value
_fileServers.update { _fileServers.update {
it.minus( it.minus(
Nip96MediaServers.ServerName(serverName, serverUrl), ServerName(serverName, serverUrl, ServerType.NIP96),
) )
} }
isModified = true isModified = true

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
data class ServerName(
val name: String,
val baseUrl: String,
val type: ServerType = ServerType.NIP96,
)
enum class ServerType {
NIP96,
NIP95,
Blossom,
}
val DEFAULT_MEDIA_SERVERS: List<ServerName> =
listOf(
ServerName("Nostr.Build", "https://nostr.build", ServerType.NIP96),
ServerName("NostrCheck.me", "https://nostrcheck.me", ServerType.NIP96),
ServerName("NostPic", "https://nostpic.com", ServerType.NIP96),
ServerName("Sovbit", "https://files.sovbit.host", ServerType.NIP96),
ServerName("Void.cat", "https://void.cat", ServerType.NIP96),
ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom),
ServerName("NostrCheck.me (Blossom)", "https://cdn.nostrcheck.me", ServerType.Blossom),
ServerName("Primal", "https://blossom.primal.net", ServerType.Blossom),
)

View File

@ -58,9 +58,9 @@ import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.core.net.toUri import androidx.core.net.toUri
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImage
@ -75,7 +75,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.service.Blurhash
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
@ -103,7 +103,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -513,6 +512,8 @@ fun ImageUrlWithDownloadButton(
text = annotatedTermsString, text = annotatedTermsString,
modifier = pressIndicator, modifier = pressIndicator,
inlineContent = inlineContent, inlineContent = inlineContent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
@ -560,20 +561,6 @@ fun aspectRatio(dim: Dimension?): Float? {
@Composable @Composable
private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) { private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
var cnt by remember { mutableStateOf<BaseMediaContent?>(null) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
delay(200)
cnt = content
}
}
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
}
@Composable
private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary val primary = MaterialTheme.colorScheme.primary
@ -620,6 +607,8 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
text = annotatedTermsString, text = annotatedTermsString,
modifier = pressIndicator, modifier = pressIndicator,
inlineContent = inlineContent, inlineContent = inlineContent,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) )
} }
@ -644,17 +633,8 @@ fun DisplayBlurHash(
) { ) {
if (blurhash == null) return if (blurhash == null) return
val context = LocalContext.current
val model =
remember {
BlurHashRequester.imageRequest(
context,
blurhash,
)
}
AsyncImage( AsyncImage(
model = model, model = Blurhash(blurhash),
contentDescription = description, contentDescription = description,
contentScale = contentScale, contentScale = contentScale,
modifier = modifier, modifier = modifier,
@ -753,7 +733,6 @@ fun ShareImageAction(
} }
} }
@OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: MediaUrlContent): Boolean? { private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null if (content.hash == null) return null

View File

@ -85,7 +85,7 @@ class MarkdownMediaRenderer(
) { ) {
if (canPreview) { if (canPreview) {
val content = val content =
parser.parseMediaUrl( parser.createMediaContent(
fullUrl = uri, fullUrl = uri,
eventTags = tags ?: EmptyTagList, eventTags = tags ?: EmptyTagList,
description = title?.ifEmpty { null } ?: startOfText, description = title?.ifEmpty { null } ?: startOfText,
@ -109,7 +109,7 @@ class MarkdownMediaRenderer(
uri: String, uri: String,
richTextStringBuilder: RichTextString.Builder, richTextStringBuilder: RichTextString.Builder,
) { ) {
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri) val content = parser.createMediaContent(uri, eventTags = tags ?: EmptyTagList, startOfText, callbackUri)
if (canPreview) { if (canPreview) {
if (content != null) { if (content != null) {

View File

@ -91,6 +91,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -134,17 +135,17 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.NewPollOption import com.vitorpamplona.amethyst.ui.actions.NewPollOption
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog
import com.vitorpamplona.amethyst.ui.actions.ServerOption
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.getPhotoUri import com.vitorpamplona.amethyst.ui.actions.getPhotoUri
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
@ -184,7 +185,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -520,8 +520,8 @@ fun NewPostScreen(
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
if (!server.isNip95) { if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server.server) accountViewModel.account.settings.changeDefaultFileServer(server)
} }
}, },
onCancel = { postViewModel.contentToAddUrl = null }, onCancel = { postViewModel.contentToAddUrl = null },
@ -1720,8 +1720,8 @@ fun CreateButton(
@Composable @Composable
fun ImageVideoDescription( fun ImageVideoDescription(
uri: Uri, uri: Uri,
defaultServer: Nip96MediaServers.ServerName, defaultServer: ServerName,
onAdd: (String, ServerOption, Boolean, Int) -> Unit, onAdd: (String, ServerName, Boolean, Int) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onError: (Int) -> Unit, onError: (Int) -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@ -1732,49 +1732,25 @@ fun ImageVideoDescription(
val isImage = mediaType.startsWith("image") val isImage = mediaType.startsWith("image")
val isVideo = mediaType.startsWith("video") val isVideo = mediaType.startsWith("video")
val listOfNip96ServersNote = val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
accountViewModel.account
.getFileServersNote()
.live()
.metadata
.observeAsState()
val fileServers = val fileServers by accountViewModel.account.liveServerList.collectAsState()
(
(listOfNip96ServersNote.value?.note?.event as? FileServersEvent)?.servers()?.map {
ServerOption(
Nip96MediaServers.ServerName(
it,
it,
),
false,
)
} ?: Nip96MediaServers.DEFAULT.map { ServerOption(it, false) }
) +
listOf(
ServerOption(
Nip96MediaServers.ServerName(
"NIP95",
stringRes(id = R.string.upload_server_relays_nip95),
),
true,
),
)
val fileServerOptions = val fileServerOptions =
remember { remember(fileServers) {
fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() fileServers
.map {
if (it.type == ServerType.NIP95) {
TitleExplainer(it.name, nip95description)
} else {
TitleExplainer(it.name, it.baseUrl)
}
}.toImmutableList()
} }
var selectedServer by remember { var selectedServer by remember {
mutableStateOf( mutableStateOf(
ServerOption( fileServers.firstOrNull { it == defaultServer } ?: fileServers[0],
fileServers
.firstOrNull { it.server == defaultServer }
?.server
?: fileServers[0].server,
false,
),
) )
} }
var message by remember { mutableStateOf("") } var message by remember { mutableStateOf("") }
@ -1903,10 +1879,9 @@ fun ImageVideoDescription(
label = stringRes(id = R.string.file_server), label = stringRes(id = R.string.file_server),
placeholder = placeholder =
fileServers fileServers
.firstOrNull { it.server == defaultServer } .firstOrNull { it == defaultServer }
?.server
?.name ?.name
?: fileServers[0].server.name, ?: fileServers[0].name,
options = fileServerOptions, options = fileServerOptions,
onSelect = { selectedServer = fileServers[it] }, onSelect = { selectedServer = fileServers[it] },
modifier = modifier =

View File

@ -113,7 +113,6 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.ServerOption
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.components.CompressorQuality import com.vitorpamplona.amethyst.ui.components.CompressorQuality
@ -518,7 +517,7 @@ fun EditFieldRow(
sensitiveContent = false, sensitiveContent = false,
// Use MEDIUM quality // Use MEDIUM quality
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM), mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
server = ServerOption(accountViewModel.account.settings.defaultFileServer, false), server = accountViewModel.account.settings.defaultFileServer,
onError = accountViewModel::toast, onError = accountViewModel::toast,
context = context, context = context,
) )

View File

@ -87,7 +87,6 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.ServerOption
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.components.CompressorQuality import com.vitorpamplona.amethyst.ui.components.CompressorQuality
@ -589,7 +588,7 @@ fun PrivateMessageEditFieldRow(
// use MEDIUM quality // use MEDIUM quality
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM), mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
isPrivate = isPrivate, isPrivate = isPrivate,
server = ServerOption(accountViewModel.account.settings.defaultFileServer, false), server = accountViewModel.account.settings.defaultFileServer,
onError = accountViewModel::toast, onError = accountViewModel::toast,
context = context, context = context,
) )

View File

@ -391,7 +391,10 @@
<string name="media_servers">Media Servers</string> <string name="media_servers">Media Servers</string>
<string name="set_preferred_media_servers">Set your preferred media upload servers.</string> <string name="set_preferred_media_servers">Set your preferred media upload servers.</string>
<string name="no_media_server_message">You have no custom media servers set. You can use Amethyst\'s list, or add one below ↓</string>
<string name="no_nip96_server_message">You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓</string>
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
<string name="built_in_media_servers_title">Built-in Media Servers</string> <string name="built_in_media_servers_title">Built-in Media Servers</string>
<string name="built_in_servers_description">Amethyst\'s default list. You can add them individually or add the list.</string> <string name="built_in_servers_description">Amethyst\'s default list. You can add them individually or add the list.</string>
<string name="use_default_servers">Use Default List</string> <string name="use_default_servers">Use Default List</string>
@ -1046,7 +1049,14 @@
<string name="http_status_508">Loop Detected - The server detects an infinite loop while processing the request</string> <string name="http_status_508">Loop Detected - The server detects an infinite loop while processing the request</string>
<string name="http_status_511">Network Authentication Required - The client must be authenticated to access the network</string> <string name="http_status_511">Network Authentication Required - The client must be authenticated to access the network</string>
<string name="media_servers_nip96_section">NIP-96 Servers</string>
<string name="media_servers_nip96_explainer">Add as many servers as you want. You can choose which one to use later when uploading your picture</string>
<string name="media_servers_blossom_section">Blossom Servers</string>
<string name="media_servers_blossom_explainer">Add as many servers as you want. You can choose which one to use later when uploading your picture</string>
<string name="add_a_nip96_server">Add a NIP-96 Server</string> <string name="add_a_nip96_server">Add a NIP-96 Server</string>
<string name="add_a_blossom_server">Add a Blossom Server</string>
<string name="delete_all">Delete all</string> <string name="delete_all">Delete all</string>
<string name="delete_all_drafts_confirmation">Are you sure you want to delete all drafts?</string> <string name="delete_all_drafts_confirmation">Are you sure you want to delete all drafts?</string>
<string name="and_more" translatable="false">" +%1$s"</string> <string name="and_more" translatable="false">" +%1$s"</string>

View File

@ -43,58 +43,54 @@ import java.util.regex.Pattern
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
class RichTextParser { class RichTextParser {
fun createImageContent( fun createMediaContent(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
description: String?,
callbackUri: String? = null,
): MediaUrlImage {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
return MediaUrlImage(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) },
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
}
fun createVideoContent(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
description: String?,
callbackUri: String? = null,
): MediaUrlVideo {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
return MediaUrlVideo(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) },
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
}
fun parseMediaUrl(
fullUrl: String, fullUrl: String,
eventTags: ImmutableListOfLists<String>, eventTags: ImmutableListOfLists<String>,
description: String?, description: String?,
callbackUri: String? = null, callbackUri: String? = null,
): MediaUrlContent? { ): MediaUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) val frags = Nip54InlineMetadata().parse(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
createImageContent(fullUrl, eventTags, description, callbackUri)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { val contentType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE]
createVideoContent(fullUrl, eventTags, description, callbackUri)
val isImage: Boolean
val isVideo: Boolean
if (contentType != null) {
isImage = contentType.startsWith("image/")
isVideo = contentType.startsWith("video/")
} else if (fullUrl.startsWith("data:")) {
isImage = fullUrl.startsWith("data:image/")
isVideo = fullUrl.startsWith("data:video/")
} else {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
}
return if (isImage) {
MediaUrlImage(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) },
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = contentType,
)
} else if (isVideo) {
MediaUrlVideo(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) } ?: tags[FileHeaderEvent.DIMENSION]?.let { Dimension.parse(it) },
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = contentType,
)
} else { } else {
null null
} }
@ -137,7 +133,7 @@ class RichTextParser {
val urlSet = parseValidUrls(content) val urlSet = parseValidUrls(content)
val imagesForPager = val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags, content, callbackUri) }.associateBy { it.url } urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, tags, content, callbackUri) }.associateBy { it.url }
val emojiMap = Nip30CustomEmoji.createEmojiMap(tags) val emojiMap = Nip30CustomEmoji.createEmojiMap(tags)
@ -148,7 +144,7 @@ class RichTextParser {
val imagesForPagerWithBase64 = val imagesForPagerWithBase64 =
imagesForPager + imagesForPager +
base64Images base64Images
.map { createImageContent(it.segmentText, tags, content, callbackUri) } .mapNotNull { createMediaContent(it.segmentText, tags, content, callbackUri) }
.associateBy { it.url } .associateBy { it.url }
return RichTextViewerState( return RichTextViewerState(

View File

@ -0,0 +1,89 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class BlossomAuthorizationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 24242
fun createGetAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) = createAuth("get", hash, alt, signer, createdAt, onReady)
fun createListAuth(
signer: NostrSigner,
alt: String,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) = createAuth("list", null, alt, signer, createdAt, onReady)
fun createDeleteAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) = createAuth("delete", hash, alt, signer, createdAt, onReady)
fun createUploadAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) = createAuth("upload", hash, alt, signer, createdAt, onReady)
private fun createAuth(
type: String,
hash: HexKey?,
alt: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) {
val tags =
listOfNotNull(
arrayOf("t", type),
arrayOf("expiration", TimeUtils.oneHourAhead().toString()),
hash?.let { arrayOf("x", it) },
)
signer.sign(createdAt, KIND, tags.toTypedArray(), alt, onReady)
}
}
}

View File

@ -0,0 +1,102 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class BlossomServersEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun dTag() = FIXED_D_TAG
fun servers(): List<String> =
tags.mapNotNull {
if (it.size > 1 && it[0] == "server") {
it[1]
} else {
null
}
}
companion object {
const val KIND = 10063
const val FIXED_D_TAG = ""
const val ALT = "File servers used by the author"
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(pubKey: HexKey): String = ATag.assembleATag(KIND, pubKey, FIXED_D_TAG)
fun createTagArray(servers: List<String>): Array<Array<String>> =
servers
.map {
arrayOf("server", it)
}.plusElement(arrayOf("alt", ALT))
.toTypedArray()
fun updateRelayList(
earlierVersion: BlossomServersEvent,
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomServersEvent) -> Unit,
) {
val tags =
earlierVersion.tags
.filter { it[0] != "server" }
.plus(
relays.map {
arrayOf("server", it)
},
).toTypedArray()
signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady)
}
fun createFromScratch(
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomServersEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
servers: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomServersEvent) -> Unit,
) {
signer.sign(createdAt, KIND, createTagArray(servers), "", onReady)
}
}
}

View File

@ -48,6 +48,8 @@ class EventFactory {
BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig)
BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)
BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)