- Migrates the old imageservers to NIP-96 for the main feed, chats, private messages, stories and user metadata uploads.

- Fixes hash calculation of the entire payload.
- Unifies uploads into NIP-94 images.
This commit is contained in:
Vitor Pamplona 2023-12-08 22:08:19 -05:00
parent 72b4f6acef
commit b3bdbbed98
23 changed files with 1027 additions and 685 deletions

View File

@ -2,73 +2,107 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.FileServer import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.ui.actions.ImageUploader import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.actions.ImgurServer import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer import com.vitorpamplona.amethyst.ui.actions.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.Base64 import java.util.Base64
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ImageUploadTesting { class ImageUploadTesting {
val contentType = "image/gif"
val image = "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" val image = "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"
private fun testBase(server: FileServer) { val contentTypePng = "image/png"
val bytes = Base64.getDecoder().decode(image) val imagePng = "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo = Nip96Retriever().loadInfo(
server.baseUrl
)
val bytes = Base64.getDecoder().decode(imagePng)
val inputStream = bytes.inputStream() val inputStream = bytes.inputStream()
val countDownLatch = CountDownLatch(1) val result = Nip96Uploader(Account(KeyPair())).uploadImage(
var url: String? = null
var error: String? = null
ImageUploader(Account(KeyPair())).uploadImage(
inputStream, inputStream,
bytes.size.toLong(), bytes.size.toLong(),
"image/gif", contentTypePng,
server, alt = null,
onSuccess = { newUrl, contentType -> sensitiveContent = null,
println("Uploaded $contentType to $url") serverInfo,
url = newUrl onProgress = {
countDownLatch.countDown()
},
onError = {
println("Failed to Upload")
error = it.message
countDownLatch.countDown()
} }
) )
countDownLatch.await() val url = result.tags!!.first() { it[0] == "url" }.get(1)
val size = result.tags!!.firstOrNull() { it[0] == "size" }?.get(1)
val dim = result.tags!!.firstOrNull() { it[0] == "dim" }?.get(1)
val hash = result.tags!!.firstOrNull() { it[0] == "x" }?.get(1)
Assert.assertNull(error) Assert.assertTrue(url.startsWith("http"))
Assert.assertTrue(url?.startsWith("http") == true)
val imageData: ByteArray = ImageDownloader().waitAndGetImage(url) ?: run {
fail("Should not be null")
return
}
FileHeader.prepare(
imageData,
contentTypePng,
null,
onReady = {
if (dim != null) {
assertEquals(dim, it.dim)
}
if (size != null) {
assertEquals(size, it.size.toString())
}
if (hash != null) {
assertEquals(hash, it.hash)
}
},
onError = {
fail("It should not fail")
}
)
} }
@Test() @Test()
fun testImgurUpload() = runBlocking { fun testNostrCheck() = runBlocking {
testBase(ImgurServer()) testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
} }
@Test() @Test()
fun testNostrBuildUpload() = runBlocking { fun testNostrage() = runBlocking {
testBase(NostrBuildServer()) testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
} }
@Test() @Test()
fun testNostrImgUpload() = runBlocking { fun testSove() = runBlocking {
testBase(NostrImgServer()) testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
} }
@Test() @Test()
fun testNostrFilesDevUpload() = runBlocking { fun testNostrBuild() = runBlocking {
testBase(NostrFilesDevServer()) testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
}
@Test()
fun testSovbit() = runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
}
@Test()
fun testVoidCat() = runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
} }
} }

View File

@ -15,13 +15,13 @@ 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.Nip47URI import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.hexToByteArray
@ -264,7 +264,7 @@ object LocalPreferences {
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices)) putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices)) putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name) putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name) putString(PrefKeys.DEFAULT_FILE_SERVER, Event.mapper.writeValueAsString(account.defaultFileServer))
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value) putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value)
@ -427,9 +427,15 @@ object LocalPreferences {
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName } LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC } ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> val defaultFileServer = try {
ServersAvailable.values().firstOrNull() { it.name == serverName } getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
} ?: ServersAvailable.NOSTR_BUILD Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
} ?: Nip96MediaServers.DEFAULT[0]
} catch (e: Exception) {
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
e.printStackTrace()
Nip96MediaServers.DEFAULT[0]
}
val zapPaymentRequestServer = try { val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {

View File

@ -4,8 +4,6 @@ import android.content.res.Resources
import android.util.Log import android.util.Log
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
@ -15,6 +13,7 @@ import androidx.lifecycle.switchMap
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.OptOutFromFilters import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
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.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
@ -132,7 +131,7 @@ class Account(
var zapAmountChoices: List<Long> = DefaultZapAmounts, var zapAmountChoices: List<Long> = DefaultZapAmounts,
var reactionChoices: List<String> = DefaultReactions, var reactionChoices: List<String> = DefaultReactions,
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD, var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
var defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS), var defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS), var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS), var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
@ -711,7 +710,7 @@ class Account(
} }
} }
fun createHTTPAuthorization(url: String, method: String, body: String? = null, onReady: (HTTPAuthorizationEvent) -> Unit) { fun createHTTPAuthorization(url: String, method: String, body: ByteArray? = null, onReady: (HTTPAuthorizationEvent) -> Unit) {
if (!isWriteable()) return if (!isWriteable()) return
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady) HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
@ -967,7 +966,13 @@ class Account(
} }
} }
fun createNip95(byteArray: ByteArray, headerInfo: FileHeader, onReady: (Pair<FileStorageEvent, FileStorageHeaderEvent>) -> Unit) { fun createNip95(
byteArray: ByteArray,
headerInfo: FileHeader,
alt: String?,
sensitiveContent: Boolean,
onReady: (Pair<FileStorageEvent, FileStorageHeaderEvent>) -> Unit
) {
if (!isWriteable()) return if (!isWriteable()) return
FileStorageEvent.create( FileStorageEvent.create(
@ -982,8 +987,8 @@ class Account(
size = headerInfo.size.toString(), size = headerInfo.size.toString(),
dimensions = headerInfo.dim, dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash, blurhash = headerInfo.blurHash,
alt = headerInfo.alt, alt = alt,
sensitiveContent = headerInfo.sensitiveContent, sensitiveContent = sensitiveContent,
signer = signer signer = signer
) { signedEvent -> ) { signedEvent ->
onReady( onReady(
@ -993,7 +998,7 @@ class Account(
} }
} }
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? { fun consumeAndSendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
if (!isWriteable()) return null if (!isWriteable()) return null
Client.send(data, relayList = relayList) Client.send(data, relayList = relayList)
@ -1005,7 +1010,19 @@ class Account(
return LocalCache.notes[signedEvent.id] return LocalCache.notes[signedEvent.id]
} }
private fun sendHeader(signedEvent: FileHeaderEvent, relayList: List<Relay>? = null, onReady: (Note) -> Unit) { fun consumeNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
LocalCache.consume(data, null)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null) {
Client.send(data, relayList = relayList)
Client.send(signedEvent, relayList = relayList)
}
fun sendHeader(signedEvent: FileHeaderEvent, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
Client.send(signedEvent, relayList = relayList) Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent, null) LocalCache.consume(signedEvent, null)
@ -1014,18 +1031,57 @@ class Account(
} }
} }
fun sendHeader(headerInfo: FileHeader, relayList: List<Relay>? = null, onReady: (Note) -> Unit) { fun createHeader(
imageUrl: String,
magnetUri: String?,
headerInfo: FileHeader,
alt: String?,
sensitiveContent: Boolean,
originalHash: String? = null,
onReady: (FileHeaderEvent) -> Unit
) {
if (!isWriteable()) return if (!isWriteable()) return
FileHeaderEvent.create( FileHeaderEvent.create(
url = headerInfo.url, url = imageUrl,
magnetUri = magnetUri,
mimeType = headerInfo.mimeType, mimeType = headerInfo.mimeType,
hash = headerInfo.hash, hash = headerInfo.hash,
size = headerInfo.size.toString(), size = headerInfo.size.toString(),
dimensions = headerInfo.dim, dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash, blurhash = headerInfo.blurHash,
alt = headerInfo.alt, alt = alt,
sensitiveContent = headerInfo.sensitiveContent, originalHash = originalHash,
sensitiveContent = sensitiveContent,
signer = signer
) { event ->
onReady(event)
}
}
fun sendHeader(
imageUrl: String,
magnetUri: String?,
headerInfo: FileHeader,
alt: String?,
sensitiveContent: Boolean,
originalHash: String? = null,
relayList: List<Relay>? = null,
onReady: (Note) -> Unit
) {
if (!isWriteable()) return
FileHeaderEvent.create(
url = imageUrl,
magnetUri = magnetUri,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent,
signer = signer signer = signer
) { event -> ) { event ->
sendHeader(event, relayList = relayList, onReady) sendHeader(event, relayList = relayList, onReady)
@ -1046,7 +1102,8 @@ class Account(
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null, relayList: List<Relay>? = null,
geohash: String? = null geohash: String? = null,
nip94attachments: List<Event>? = null
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@ -1072,6 +1129,7 @@ class Account(
zapRaiserAmount = zapRaiserAmount, zapRaiserAmount = zapRaiserAmount,
directMentions = directMentions, directMentions = directMentions,
geohash = geohash, geohash = geohash,
nip94attachments = nip94attachments,
signer = signer signer = signer
) { ) {
Client.send(it, relayList = relayList) Client.send(it, relayList = relayList)
@ -1102,7 +1160,8 @@ class Account(
root: String?, root: String?,
directMentions: Set<HexKey>, directMentions: Set<HexKey>,
relayList: List<Relay>? = null, relayList: List<Relay>? = null,
geohash: String? = null geohash: String? = null,
nip94attachments: List<Event>? = null
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@ -1123,6 +1182,7 @@ class Account(
root = root, root = root,
directMentions = directMentions, directMentions = directMentions,
geohash = geohash, geohash = geohash,
nip94attachments = nip94attachments,
signer = signer signer = signer
) { ) {
Client.send(it, relayList = relayList) Client.send(it, relayList = relayList)
@ -1160,7 +1220,8 @@ class Account(
wantsToMarkAsSensitive: Boolean, wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null, relayList: List<Relay>? = null,
geohash: String? = null geohash: String? = null,
nip94attachments: List<Event>? = null
) { ) {
if (!isWriteable()) return if (!isWriteable()) return
@ -1182,7 +1243,8 @@ class Account(
zapReceiver = zapReceiver, zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive, markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount, zapRaiserAmount = zapRaiserAmount,
geohash = geohash geohash = geohash,
nip94attachments = nip94attachments
) { ) {
Client.send(it, relayList = relayList) Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null) LocalCache.justConsume(it, null)
@ -1201,7 +1263,7 @@ class Account(
} }
} }
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List<Event>? = null) {
if (!isWriteable()) return if (!isWriteable()) return
val repliesToHex = replyTo?.map { it.idHex } val repliesToHex = replyTo?.map { it.idHex }
@ -1216,6 +1278,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive, markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount, zapRaiserAmount = zapRaiserAmount,
geohash = geohash, geohash = geohash,
nip94attachments = nip94attachments,
signer = signer signer = signer
) { ) {
Client.send(it) Client.send(it)
@ -1223,7 +1286,7 @@ class Account(
} }
} }
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List<Event>? = null) {
if (!isWriteable()) return if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@ -1239,6 +1302,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive, markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount, zapRaiserAmount = zapRaiserAmount,
geohash = geohash, geohash = geohash,
nip94attachments = nip94attachments,
signer = signer signer = signer
) { ) {
Client.send(it) Client.send(it)
@ -1674,7 +1738,7 @@ class Account(
saveable.invalidateData() saveable.invalidateData()
} }
fun changeDefaultFileServer(server: ServersAvailable) { fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
defaultFileServer = server defaultFileServer = server
live.invalidateData() live.invalidateData()
saveable.invalidateData() saveable.invalidateData()

View File

@ -1,16 +0,0 @@
package com.vitorpamplona.amethyst.model
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}

View File

@ -10,22 +10,25 @@ import io.trbl.blurhash.BlurHash
import kotlin.math.roundToInt import kotlin.math.roundToInt
class FileHeader( class FileHeader(
val url: String,
val mimeType: String?, val mimeType: String?,
val hash: String, val hash: String,
val size: Int, val size: Int,
val dim: String?, val dim: String?,
val blurHash: String?, val blurHash: String?
val alt: String? = null,
val sensitiveContent: Boolean = false
) { ) {
companion object { companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) { suspend fun prepare(
fileUrl: String,
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
try { try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl) val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) { if (imageData != null) {
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError) prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
} else { } else {
onError() onError()
} }
@ -37,10 +40,8 @@ class FileHeader(
fun prepare( fun prepare(
data: ByteArray, data: ByteArray,
fileUrl: String,
mimeType: String?, mimeType: String?,
alt: String?, dimPrecomputed: String?,
sensitiveContent: Boolean,
onReady: (FileHeader) -> Unit, onReady: (FileHeader) -> Unit,
onError: () -> Unit onError: () -> Unit
) { ) {
@ -76,10 +77,10 @@ class FileHeader(
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
} }
} else { } else {
Pair(null, null) Pair(null, dimPrecomputed)
} }
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent)) onReady(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError() onError()

View File

@ -0,0 +1,92 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import okhttp3.Request
object Nip96MediaServers {
val DEFAULT =
listOf(
ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("Nostrage", "https://nostrage.com"),
ServerName("Sove", "https://sove.rent"),
ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat")
)
data class ServerName(val name: String, val baseUrl: String)
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
suspend fun load(url: String): Nip96Retriever.ServerInfo {
val cached = cache[url]
if (cached != null) return cached
val fetched = Nip96Retriever().loadInfo(url)
cache[url] = fetched
return fetched
}
}
class Nip96Retriever {
data class ServerInfo(
@JsonProperty("api_url") val apiUrl: String,
@JsonProperty("download_url") val downloadUrl: String? = null,
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
@JsonProperty("tos_url") val tosUrl: String? = null,
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf()
)
data class Plan(
@JsonProperty("name") val name: String? = null,
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
@JsonProperty("url") val url: String? = null,
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
@JsonProperty("media_transformations") val mediaTransformations: Map<MimeType, Array<String>> = emptyMap()
)
fun parse(body: String): ServerInfo {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, ServerInfo::class.java)
}
suspend fun loadInfo(
baseUrl: String
): ServerInfo {
checkNotInMainThread()
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
HttpClient.getHttpClient()
.newCall(request)
.execute().use { response ->
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
return parse(body)
} else {
throw RuntimeException("Resulting Message from $baseUrl is an error: ${response.code} ${response.message}")
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e
}
}
}
}
}
typealias PlanName = String
typealias MimeType = String

View File

@ -1,287 +0,0 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.IOException
import okio.source
import java.io.InputStream
import java.time.Duration
import java.util.Base64
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
class ImageUploader(val account: Account?) {
fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
server: ServersAvailable,
contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
val myServer = when (server) {
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
// ImgurServer()
// }
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer()
}
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
NostrBuildServer()
}
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
NostrFilesDevServer()
}
ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> {
NostrCheckMeServer()
}
else -> {
NostrBuildServer()
}
}
uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError)
}
fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
server: FileServer,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient(Duration.ofSeconds(30L))
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
server.inputParameterName(contentType),
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
)
.build()
server.authorizationToken(account, requestBody.toString()) { authorizationToken ->
if (authorizationToken != null) {
requestBuilder.addHeader("Authorization", authorizationToken)
}
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.postUrl(contentType))
.post(requestBody)
val request = requestBuilder.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
try {
if (response.isSuccessful) {
response.body.use { body ->
val url = server.parseUrlFromSuccess(body.string(), authorizationToken)
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
onSuccess(url, contentType)
}
} else {
onError(RuntimeException("Error Uploading image: ${response.code}"))
}
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
})
}
}
fun NIP98Header(url: String, method: String, body: String, onReady: (String?) -> Unit) {
val myAccount = account
if (myAccount == null) {
onReady(null)
return
}
myAccount.createHTTPAuthorization(url, method, body) {
val noteJson = it.toJson()
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
onReady("Nostr $encodedNIP98Event")
}
}
}
abstract class FileServer {
abstract fun postUrl(contentType: String?): String
abstract fun parseUrlFromSuccess(body: String, authorizationToken: String?): String?
abstract fun inputParameterName(contentType: String?): String
open fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
onReady(null)
}
}
class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
}
class ImgurServer : FileServer() {
override fun postUrl(contentType: String?): String {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
}
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
onReady("Client-ID e6aea87296f3f96")
}
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
val tree = jacksonObjectMapper().readTree(body)
val data = tree?.get("data")
val data0 = data?.get(0)
val url = data0?.get("url")
return url.toString().replace("\"", "")
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
ImageUploader(account).NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info, onReady)
}
}
class NostrFilesDevServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
val tree = jacksonObjectMapper().readTree(body)
return tree?.get("url")?.asText()
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
}
class NostrCheckMeServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
checkNotInMainThread()
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("url")?.asText()
val id = tree?.get("id")?.asText()
var isCompleted = false
val client = HttpClient.getHttpClient()
val requrl =
"https://nostrcheck.me/api/v1/media?id=$id" // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
val requestBuilder = Request.Builder().url(requrl)
if (authorizationToken != null) {
requestBuilder.addHeader("Authorization", authorizationToken)
}
val request = requestBuilder.get().build()
while (!isCompleted) {
client.newCall(request).execute().use {
val tree = jacksonObjectMapper().readTree(it.body.string())
isCompleted = tree?.get("status")?.asText() == "completed"
try {
Thread.sleep(500)
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
}
return url
}
override fun inputParameterName(contentType: String?): String {
return "mediafile"
}
override fun authorizationToken(account: Account?, body: String, onReady: (String?) -> Unit) {
ImageUploader(account).NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body, onReady)
}
}

View File

@ -10,8 +10,8 @@ import androidx.compose.runtime.setValue
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.model.ServersAvailable
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -19,6 +19,11 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
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
@ -27,7 +32,7 @@ open class NewMediaModel : ViewModel() {
val imageUploadingError = MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) val imageUploadingError = MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
var mediaType by mutableStateOf<String?>(null) var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null) var selectedServer by mutableStateOf<ServerOption?>(null)
var alt by mutableStateOf("") var alt by mutableStateOf("")
var sensitiveContent by mutableStateOf(false) var sensitiveContent by mutableStateOf(false)
@ -43,20 +48,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 = defaultServer() this.selectedServer = ServerOption(defaultServer(), false)
// if (selectedServer == ServersAvailable.IMGUR) {
// selectedServer = ServersAvailable.IMGUR_NIP_94
// } else
if (selectedServer == ServersAvailable.NOSTRIMG) {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRCHECK_ME) {
selectedServer = ServersAvailable.NOSTRCHECK_ME_NIP_94
}
} }
fun upload(context: Context, relayList: List<Relay>? = null) { fun upload(context: Context, relayList: List<Relay>? = null) {
@ -76,8 +68,7 @@ open class NewMediaModel : ViewModel() {
contentType, contentType,
context.applicationContext, context.applicationContext,
onReady = { fileUri, contentType, size -> onReady = { fileUri, contentType, size ->
if (serverToUse.isNip95) {
if (selectedServer == ServersAvailable.NIP95) {
uploadingPercentage.value = 0.2f uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading" uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use { contentResolver.openInputStream(fileUri)?.use {
@ -95,30 +86,35 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.2f uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading" uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
ImageUploader(account).uploadImage( try {
uri = fileUri, val result = Nip96Uploader(account).uploadImage(
contentType = contentType, uri = fileUri,
size = size, contentType = contentType,
server = serverToUse, size = size,
contentResolver = contentResolver, alt = alt,
onSuccess = { imageUrl, mimeType -> sensitiveContent = if (sensitiveContent) "" else null,
createNIP94Record( server = serverToUse.server,
imageUrl, contentResolver = contentResolver,
mimeType, onProgress = { percent: Float ->
alt, uploadingPercentage.value = 0.2f + (0.2f * percent)
sensitiveContent,
relayList = relayList
)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
} }
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList
)
} catch (e: Exception) {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload: ${e.message}")
} }
) }
} }
} }
}, },
@ -142,63 +138,83 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.0f uploadingPercentage.value = 0.0f
alt = "" alt = ""
selectedServer = account?.defaultFileServer selectedServer = ServerOption(defaultServer(), false)
} }
fun canPost(): Boolean { fun canPost(): Boolean {
return !isUploadingImage && galleryUri != null && selectedServer != null return !isUploadingImage && galleryUri != null && selectedServer != null
} }
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) { suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<Relay>? = null
) {
uploadingPercentage.value = 0.40f uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) { 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 imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl) 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 }
val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null }
uploadingDescription.value = "Downloading" if (imageUrl.isNullOrBlank()) {
uploadingPercentage.value = 0.60f Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
return
}
if (imageData != null) { uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.80f uploadingPercentage.value = 0.60f
uploadingDescription.value = "Hashing"
FileHeader.prepare( val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
imageData,
imageUrl, if (imageData != null) {
mimeType, uploadingPercentage.value = 0.80f
alt, uploadingDescription.value = "Hashing"
sensitiveContent,
onReady = { FileHeader.prepare(
uploadingPercentage.value = 0.90f data = imageData,
uploadingDescription.value = "Sending" mimeType = remoteMimeType ?: localContentType,
account?.sendHeader(it, relayList) { dimPrecomputed = dim,
uploadingPercentage.value = 1.00f onReady = {
isUploadingImage = false uploadingPercentage.value = 0.90f
onceUploaded() uploadingDescription.value = "Sending"
cancel() account?.sendHeader(imageUrl, magnet, it, alt, sensitiveContent, originalHash, relayList) {
} uploadingPercentage.value = 1.00f
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false isUploadingImage = false
viewModelScope.launch { onceUploaded()
imageUploadingError.emit("Failed to upload the image / video") cancel()
} }
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
} }
)
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
} }
)
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
} }
} }
} }
@ -210,17 +226,15 @@ open class NewMediaModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare( FileHeader.prepare(
bytes, bytes,
"",
mimeType, mimeType,
alt, null,
sensitiveContent,
onReady = { onReady = {
uploadingDescription.value = "Signing" uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.40f uploadingPercentage.value = 0.40f
account?.createNip95(bytes, headerInfo = it) { nip95 -> account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
uploadingDescription.value = "Sending" uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f uploadingPercentage.value = 0.60f
account?.sendNip95(nip95.first, nip95.second, relayList) account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
uploadingPercentage.value = 1.00f uploadingPercentage.value = 1.00f
isUploadingImage = false isUploadingImage = false
@ -243,7 +257,7 @@ open class NewMediaModel : ViewModel() {
fun isImage() = mediaType?.startsWith("image") fun isImage() = mediaType?.startsWith("image")
fun isVideo() = mediaType?.startsWith("video") fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.defaultFileServer fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
fun onceUploaded(onceUploaded: () -> Unit) { fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded this.onceUploaded = onceUploaded
} }

View File

@ -46,7 +46,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ServersAvailable import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@ -146,7 +146,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
onPost = { onPost = {
onClose() onClose()
postViewModel.upload(context, relayList) postViewModel.upload(context, relayList)
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) } postViewModel.selectedServer?.let { account.changeDefaultFileServer(it.server) }
}, },
isActive = postViewModel.canPost() isActive = postViewModel.canPost()
) )
@ -170,26 +170,19 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
} }
} }
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
// selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94 ||
selectedServer == ServersAvailable.NOSTRCHECK_ME_NIP_94
}
@Composable @Composable
fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) { fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) {
val fileServers = listOf( val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), ServerOption(
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), Nip96MediaServers.ServerName(
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), "NIP95",
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)), stringResource(id = R.string.upload_server_relays_nip95)
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)), ),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) true
)
) )
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() } val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
val resolver = LocalContext.current.contentResolver val resolver = LocalContext.current.contentResolver
Row( Row(
@ -252,10 +245,12 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
) { ) {
TextSpinner( TextSpinner(
label = stringResource(id = R.string.file_server), label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second, placeholder = fileServers.firstOrNull {
it.server == accountViewModel.account.defaultFileServer
}?.server?.name ?: fileServers[0].server.name,
options = fileServerOptions, options = fileServerOptions,
onSelect = { onSelect = {
postViewModel.selectedServer = fileServers[it].first postViewModel.selectedServer = fileServers[it]
}, },
modifier = Modifier modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
@ -263,44 +258,40 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
) )
} }
if (isNIP94Server(postViewModel.selectedServer) || Row(
postViewModel.selectedServer == ServersAvailable.NIP95 verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) { ) {
Row( SettingSwitchItem(
verticalAlignment = Alignment.CenterVertically, checked = postViewModel.sensitiveContent,
modifier = Modifier.fillMaxWidth() onCheckedChange = { postViewModel.sensitiveContent = it },
) { title = R.string.add_sensitive_content_label,
SettingSwitchItem( description = R.string.add_sensitive_content_description
checked = postViewModel.sensitiveContent, )
onCheckedChange = { postViewModel.sensitiveContent = it }, }
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) { value = postViewModel.alt,
OutlinedTextField( onValueChange = { postViewModel.alt = it },
label = { Text(text = stringResource(R.string.content_description)) }, placeholder = {
modifier = Modifier Text(
.fillMaxWidth() text = stringResource(R.string.content_description_example),
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), color = MaterialTheme.colorScheme.placeholderText
value = postViewModel.alt,
onValueChange = { postViewModel.alt = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
) )
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
) )
} )
} }
} }

View File

@ -112,8 +112,8 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
@ -404,8 +404,8 @@ fun NewPostView(
url, url,
accountViewModel.account.defaultFileServer, accountViewModel.account.defaultFileServer,
onAdd = { alt, server, sensitiveContent -> onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, server, context, relayList) postViewModel.upload(url, alt, sensitiveContent, false, server, context, relayList)
accountViewModel.account.changeDefaultFileServer(server) accountViewModel.account.changeDefaultFileServer(server.server)
}, },
onCancel = { onCancel = {
postViewModel.contentToAddUrl = null postViewModel.contentToAddUrl = null
@ -1597,8 +1597,8 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
@Composable @Composable
fun ImageVideoDescription( fun ImageVideoDescription(
uri: Uri, uri: Uri,
defaultServer: ServersAvailable, defaultServer: Nip96MediaServers.ServerName,
onAdd: (String, ServersAvailable, Boolean) -> Unit, onAdd: (String, ServerOption, Boolean) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
@ -1609,23 +1609,19 @@ fun ImageVideoDescription(
val isImage = mediaType.startsWith("image") val isImage = mediaType.startsWith("image")
val isVideo = mediaType.startsWith("video") val isVideo = mediaType.startsWith("video")
val fileServers = listOf( val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
// Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)), ServerOption(
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)), Nip96MediaServers.ServerName(
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)), "NIP95",
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)), stringResource(id = R.string.upload_server_relays_nip95)
Triple(ServersAvailable.NOSTRCHECK_ME, stringResource(id = R.string.upload_server_nostrcheckme), stringResource(id = R.string.upload_server_nostrcheckme_explainer)), ),
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), true
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), )
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
) )
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() } val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
var selectedServer by remember { mutableStateOf(defaultServer) } var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) }
var message by remember { mutableStateOf("") } var message by remember { mutableStateOf("") }
var sensitiveContent by remember { mutableStateOf(false) } var sensitiveContent by remember { mutableStateOf(false) }
@ -1735,10 +1731,12 @@ fun ImageVideoDescription(
) { ) {
TextSpinner( TextSpinner(
label = stringResource(id = R.string.file_server), label = stringResource(id = R.string.file_server),
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second, placeholder = fileServers.firstOrNull {
it.server == accountViewModel.account.defaultFileServer
}?.server?.name ?: fileServers[0].server.name,
options = fileServerOptions, options = fileServerOptions,
onSelect = { onSelect = {
selectedServer = fileServers[it].first selectedServer = fileServers[it]
}, },
modifier = Modifier modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
@ -1746,45 +1744,41 @@ fun ImageVideoDescription(
) )
} }
if (isNIP94Server(selectedServer) || Row(
selectedServer == ServersAvailable.NIP95 verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) { ) {
Row( SettingSwitchItem(
verticalAlignment = Alignment.CenterVertically, checked = sensitiveContent,
modifier = Modifier.fillMaxWidth() onCheckedChange = { sensitiveContent = it },
) { title = R.string.add_sensitive_content_label,
SettingSwitchItem( description = R.string.add_sensitive_content_description
checked = sensitiveContent, )
onCheckedChange = { sensitiveContent = it }, }
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) { value = message,
OutlinedTextField( onValueChange = { message = it },
label = { Text(text = stringResource(R.string.content_description)) }, placeholder = {
modifier = Modifier Text(
.fillMaxWidth() text = stringResource(R.string.content_description_example),
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), color = MaterialTheme.colorScheme.placeholderText
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
) )
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
) )
} )
} }
Button( Button(

View File

@ -18,7 +18,6 @@ import com.fonfon.kgeohash.toGeoHash
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.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil import com.vitorpamplona.amethyst.service.LocationUtil
@ -35,10 +34,14 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.events.findURLs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -63,6 +66,9 @@ open class NewPostViewModel() : ViewModel() {
var mentions by mutableStateOf<List<User>?>(null) var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<List<Note>?>(null) var replyTos by mutableStateOf<List<Note>?>(null)
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue("")) var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null) var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false) var isUploadingImage by mutableStateOf(false)
@ -219,11 +225,25 @@ open class NewPostViewModel() : ViewModel() {
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
nip95attachments.forEach {
if (replyTos?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) {
account?.sendNip95(it.first, it.second, relayList)
}
}
val urls = findURLs(tagger.message)
val usedAttachments = nip94attachments.filter {
it.urls().intersect(urls).isNotEmpty()
}
usedAttachments.forEach {
account?.sendHeader(it, relayList, { })
}
if (originalNote?.channelHex() != null) { if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) { if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash) account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
} else { } else {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash) account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
} }
} else if (originalNote?.event is PrivateDmEvent) { } else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash) account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
@ -281,7 +301,8 @@ open class NewPostViewModel() : ViewModel() {
wantsToMarkAsSensitive, wantsToMarkAsSensitive,
localZapRaiserAmount, localZapRaiserAmount,
relayList, relayList,
geoHash geoHash,
nip94attachments = usedAttachments
) )
} else if (wantsProduct) { } else if (wantsProduct) {
account?.sendClassifieds( account?.sendClassifieds(
@ -298,7 +319,8 @@ open class NewPostViewModel() : ViewModel() {
wantsToMarkAsSensitive = wantsToMarkAsSensitive, wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount, zapRaiserAmount = localZapRaiserAmount,
relayList = relayList, relayList = relayList,
geohash = geoHash geohash = geoHash,
nip94attachments = usedAttachments
) )
} else { } else {
// adds markers // adds markers
@ -322,7 +344,8 @@ open class NewPostViewModel() : ViewModel() {
root = rootId, root = rootId,
directMentions = tagger.directMentions, directMentions = tagger.directMentions,
relayList = relayList, relayList = relayList,
geohash = geoHash geohash = geoHash,
nip94attachments = usedAttachments
) )
} }
} }
@ -330,7 +353,15 @@ open class NewPostViewModel() : ViewModel() {
cancel() cancel()
} }
fun upload(galleryUri: Uri, alt: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) { fun upload(
galleryUri: Uri,
alt: String?,
sensitiveContent: Boolean,
isPrivate: Boolean = false,
server: ServerOption,
context: Context,
relayList: List<Relay>? = null
) {
isUploadingImage = true isUploadingImage = true
contentToAddUrl = null contentToAddUrl = null
@ -343,35 +374,49 @@ open class NewPostViewModel() : ViewModel() {
contentType, contentType,
context.applicationContext, context.applicationContext,
onReady = { fileUri, contentType, size -> onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) { if (server.isNip95) {
contentResolver.openInputStream(fileUri)?.use { contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList) createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
} }
} else { } else {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
ImageUploader(account).uploadImage( try {
uri = fileUri, val result = Nip96Uploader(account).uploadImage(
contentType = contentType, uri = fileUri,
size = size, contentType = contentType,
server = server, size = size,
contentResolver = contentResolver, alt = alt,
onSuccess = { imageUrl, mimeType -> sensitiveContent = if (sensitiveContent) "" else null,
if (isNIP94Server(server)) { server = server.server,
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent) contentResolver = contentResolver,
} else { onProgress = { }
isUploadingImage = false )
message = TextFieldValue(message.text + "\n" + imageUrl)
urlPreview = findUrlInMessage() if (!isPrivate) {
} createNIP94Record(
}, uploadingResult = result,
onError = { localContentType = contentType,
Log.e("ImageUploader", "Failed to upload the image / video", it) alt = alt,
sensitiveContent = sensitiveContent
)
} else {
val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
isUploadingImage = false isUploadingImage = false
viewModelScope.launch { message = TextFieldValue(message.text + "\n" + url)
imageUploadingError.emit("Failed to upload the image / video") urlPreview = findUrlInMessage()
}
} }
) } catch (e: Exception) {
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e
)
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload: ${e.message}")
}
}
} }
} }
}, },
@ -576,44 +621,66 @@ open class NewPostViewModel() : ViewModel() {
} }
} }
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) { suspend fun createNIP94Record(
viewModelScope.launch(Dispatchers.IO) { uploadingResult: Nip96Uploader.PartialEvent,
// Images don't seem to be ready immediately after upload localContentType: String?,
FileHeader.prepare( alt: String?,
imageUrl, sensitiveContent: Boolean
mimeType, ) {
alt, // Images don't seem to be ready immediately after upload
sensitiveContent, val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
onReady = { val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
account?.sendHeader(it, relayList = relayList) { note -> val originalHash = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
isUploadingImage = false val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null }
message = TextFieldValue(message.text + "\nnostr:" + note.toNEvent()) if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
urlPreview = findUrlInMessage() cancel()
} isUploadingImage = false
}, viewModelScope.launch {
onError = { imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false }
viewModelScope.launch { return
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} }
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false
nip94attachments = nip94attachments + event
message = TextFieldValue(message.text + "\n" + imageUrl)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} }
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) { fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
relayList: List<Relay>? = null
) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare( FileHeader.prepare(
bytes, bytes,
"",
mimeType, mimeType,
alt, null,
sensitiveContent,
onReady = { onReady = {
account?.createNip95(bytes, headerInfo = it) { nip95 -> account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
val note = nip95.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) } nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
isUploadingImage = false isUploadingImage = false

View File

@ -184,23 +184,35 @@ class NewUserMetadataViewModel : ViewModel() {
context.applicationContext, context.applicationContext,
onReady = { fileUri, contentType, size -> onReady = { fileUri, contentType, size ->
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
ImageUploader(account).uploadImage( try {
uri = fileUri, val result = Nip96Uploader(account).uploadImage(
contentType = contentType, uri = fileUri,
size = size, contentType = contentType,
server = account.defaultFileServer, size = size,
contentResolver = contentResolver, alt = null,
onSuccess = { imageUrl, mimeType -> sensitiveContent = null,
server = account.defaultFileServer,
contentResolver = contentResolver,
onProgress = { }
)
val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
if (url != null) {
onUploading(false) onUploading(false)
onUploaded(imageUrl) onUploaded(url)
}, } else {
onError = {
onUploading(false) onUploading(false)
viewModelScope.launch { viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video") imageUploadingError.emit("Failed to upload the image / video")
} }
} }
) } catch (e: Exception) {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
} }
}, },
onError = { onError = {

View File

@ -0,0 +1,237 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
class Nip96Uploader(val account: Account?) {
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96MediaServers.ServerName,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
val serverInfo = Nip96Retriever().loadInfo(
server.baseUrl
)
return uploadImage(uri, contentType, size, alt, sensitiveContent, serverInfo, contentResolver, onProgress)
}
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
return uploadImage(imageInputStream, length, myContentType, alt, sensitiveContent, server, onProgress)
}
suspend fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
checkNotInMainThread()
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("expiration", "")
.addFormDataPart("size", length.toString())
.also { body ->
alt?.let { body.addFormDataPart("alt", it) }
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
contentType?.let { body.addFormDataPart("content_type", it) }
}
.addFormDataPart(
"file",
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
)
.build()
nip98Header(server.apiUrl)?.let {
requestBuilder.addHeader("Authorization", it)
}
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl)
.post(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)
if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, onProgress)
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
} else {
throw RuntimeException("Failed to upload with message: ${result.message}")
}
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
}
}
private suspend fun waitProcessing(
result: Nip96Result,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
val client = HttpClient.getHttpClient()
var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
onProgress((currentResult.percentage ?: 100) / 100f)
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(result.processingUrl)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
it.body.use {
currentResult = parseResults(it.string())
}
}
}
delay(500)
}
onProgress((currentResult.percentage ?: 100) / 100f)
val nip94 = currentResult.nip94Event
if (nip94 != null) {
return nip94
} else {
throw RuntimeException("Error waiting for processing. Final result is unavailable")
}
}
suspend fun nip98Header(url: String): String? {
return withTimeoutOrNull(5000) {
suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken ->
continuation.resume(authorizationToken)
}
}
}
}
fun nip98Header(url: String, method: String, file: ByteArray? = null, onReady: (String?) -> Unit) {
val myAccount = account
if (myAccount == null) {
onReady(null)
return
}
myAccount.createHTTPAuthorization(url, method, file) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
onReady("Nostr $encodedNIP98Event")
}
}
data class Nip96Result(
val status: String,
val message: String? = null,
@JsonProperty("processing_url")
val processingUrl: String? = null,
val percentage: Int? = null,
@JsonProperty("nip94_event")
val nip94Event: PartialEvent? = null
)
class PartialEvent(
val tags: Array<Array<String>>? = null,
val content: String? = null
)
private fun parseResults(body: String): Nip96Result {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, Nip96Result::class.java)
}
}

View File

@ -85,12 +85,12 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
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.PublicChatChannel import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChannelDataSource 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.components.LoadNote import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
@ -401,24 +401,13 @@ fun EditFieldRow(
tint = MaterialTheme.colorScheme.placeholderText, tint = MaterialTheme.colorScheme.placeholderText,
modifier = EditFieldLeadingIconModifier modifier = EditFieldLeadingIconModifier
) { ) {
val fileServer = if (isPrivate) { channelScreenModel.upload(
// TODO: Make private servers galleryUri = it,
when (accountViewModel.account.defaultFileServer) { alt = null,
ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD sensitiveContent = false,
ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG server = ServerOption(accountViewModel.account.defaultFileServer, false),
ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV context = context
ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME )
ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD
ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG
ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV
ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME
ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD
}
} else {
accountViewModel.account.defaultFileServer
}
channelScreenModel.upload(it, "", false, fileServer, context)
} }
}, },
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(

View File

@ -65,12 +65,12 @@ import androidx.lifecycle.map
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.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton import com.vitorpamplona.amethyst.ui.actions.PostButton
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.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@ -379,26 +379,14 @@ fun PrivateMessageEditFieldRow(
.size(30.dp) .size(30.dp)
.padding(start = 2.dp) .padding(start = 2.dp)
) { ) {
val fileServer = if (isPrivate) { channelScreenModel.upload(
// TODO: Make private servers galleryUri = it,
when (accountViewModel.account.defaultFileServer) { alt = null,
ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD sensitiveContent = false,
ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG isPrivate = isPrivate,
ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV server = ServerOption(accountViewModel.account.defaultFileServer, false),
ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME context = context
)
ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD
ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG
ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV
ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME
ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD
}
} else {
accountViewModel.account.defaultFileServer
}
channelScreenModel.upload(it, "", false, fileServer, context)
} }
var wantsToActivateNIP24 by remember { var wantsToActivateNIP24 by remember {

View File

@ -0,0 +1,119 @@
package com.vitorpamplona.amethyst.service
import junit.framework.TestCase.assertEquals
import org.junit.Test
class Nip96Test {
val json = """
{
"api_url": "https://nostr.build/api/v2/nip96/upload",
"download_url": "https://media.nostr.build",
"supported_nips": [
94,
96,
98
],
"tos_url": "https://nostr.build/tos/",
"content_types": [
"image/*",
"video/*",
"audio/*"
],
"plans": {
"free": {
"name": "Free",
"is_nip98_required": true,
"url": "https://nostr.build",
"max_byte_size": 26214400,
"file_expiration": [
0,
0
],
"media_transformations": {
"image": [
"resizing",
"format_conversion",
"compression",
"metadata_stripping"
],
"video": [
"resizing",
"format_conversion",
"compression"
]
}
},
"professional": {
"name": "Professional",
"is_nip98_required": true,
"url": "https://nostr.build/signup/new/",
"max_byte_size": 10737418240,
"file_expiration": [
0,
0
],
"media_transformations": {
"image": [
"resizing",
"format_conversion",
"compression",
"metadata_stripping"
],
"video": [
"resizing",
"format_conversion",
"compression"
]
}
},
"creator": {
"name": "Creator",
"is_nip98_required": true,
"url": "https://nostr.build/signup/new/",
"max_byte_size": 26843545600,
"file_expiration": [
0,
0
],
"media_transformations": {
"image": [
"resizing",
"format_conversion",
"compression",
"metadata_stripping"
],
"video": [
"resizing",
"format_conversion",
"compression"
]
}
}
}
}
""".trimIndent()
@Test()
fun parseNostrBuild() {
val info = Nip96Retriever().parse(json)
assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl)
assertEquals("https://media.nostr.build", info.downloadUrl)
assertEquals(listOf(94, 96, 98), info.supportedNips)
assertEquals("https://nostr.build/tos/", info.tosUrl)
assertEquals(listOf("image/*", "video/*", "audio/*"), info.contentTypes)
assertEquals(listOf("creator", "free", "professional"), info.plans.keys.sorted())
assertEquals("Free", info.plans["free"]?.name)
assertEquals(true, info.plans["free"]?.isNip98Required)
assertEquals("https://nostr.build", info.plans["free"]?.url)
assertEquals(26214400L, info.plans["free"]?.maxByteSize)
assertEquals(listOf(0, 0), info.plans["free"]?.fileExpiration)
assertEquals(listOf("image", "video"), info.plans["free"]?.mediaTransformations?.keys?.sorted())
assertEquals(26843545600L, info.plans["creator"]?.maxByteSize)
assertEquals(10737418240L, info.plans["professional"]?.maxByteSize)
}
}

View File

@ -37,6 +37,7 @@ class ChannelMessageEvent(
markAsSensitive: Boolean, markAsSensitive: Boolean,
zapRaiserAmount: Long?, zapRaiserAmount: Long?,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null,
onReady: (ChannelMessageEvent) -> Unit onReady: (ChannelMessageEvent) -> Unit
) { ) {
val tags = mutableListOf( val tags = mutableListOf(
@ -60,6 +61,11 @@ class ChannelMessageEvent(
geohash?.let { geohash?.let {
tags.addAll(geohashMipMap(it)) tags.addAll(geohashMipMap(it))
} }
nip94attachments?.let {
it.forEach {
tags.add(arrayOf("nip94", it.toJson()))
}
}
signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady) signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady)
} }

View File

@ -63,6 +63,7 @@ class ClassifiedsEvent(
markAsSensitive: Boolean, markAsSensitive: Boolean,
zapRaiserAmount: Long?, zapRaiserAmount: Long?,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null,
signer: NostrSigner, signer: NostrSigner,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
onReady: (ClassifiedsEvent) -> Unit onReady: (ClassifiedsEvent) -> Unit
@ -133,6 +134,11 @@ class ClassifiedsEvent(
geohash?.let { geohash?.let {
tags.addAll(geohashMipMap(it)) tags.addAll(geohashMipMap(it))
} }
nip94attachments?.let {
it.forEach {
tags.add(arrayOf("nip94", it.toJson()))
}
}
signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady) signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady)
} }

View File

@ -19,6 +19,8 @@ class FileHeaderEvent(
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] }
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
@ -43,16 +45,19 @@ class FileHeaderEvent(
private const val MAGNET_URI = "magnet" private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i" private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash" private const val BLUR_HASH = "blurhash"
private const val ORIGINAL_HASH = "ox"
private const val ALT = "alt" private const val ALT = "alt"
fun create( fun create(
url: String, url: String,
magnetUri: String? = null,
mimeType: String? = null, mimeType: String? = null,
alt: String? = null, alt: String? = null,
hash: String? = null, hash: String? = null,
size: String? = null, size: String? = null,
dimensions: String? = null, dimensions: String? = null,
blurhash: String? = null, blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null, magnetURI: String? = null,
torrentInfoHash: String? = null, torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null, encryptionKey: AESGCM? = null,
@ -63,12 +68,14 @@ class FileHeaderEvent(
) { ) {
val tags = listOfNotNull( val tags = listOfNotNull(
arrayOf(URL, url), arrayOf(URL, url),
mimeType?.let { arrayOf(MIME_TYPE, mimeType) }, magnetUri?.let { arrayOf(MAGNET_URI, it) },
mimeType?.let { arrayOf(MIME_TYPE, it) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) }, alt?.ifBlank { null }?.let { arrayOf(ALT, it) },
hash?.let { arrayOf(HASH, it) }, hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) }, size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it) }, dimensions?.let { arrayOf(DIMENSION, it) },
blurhash?.let { arrayOf(BLUR_HASH, it) }, blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) }, magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },

View File

@ -24,14 +24,14 @@ class HTTPAuthorizationEvent(
fun create( fun create(
url: String, url: String,
method: String, method: String,
body: String? = null, file: ByteArray? = null,
signer: NostrSigner, signer: NostrSigner,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
onReady: (HTTPAuthorizationEvent) -> Unit onReady: (HTTPAuthorizationEvent) -> Unit
) { ) {
var hash = "" var hash = ""
body?.let { file?.let {
hash = CryptoUtils.sha256(it.toByteArray()).toHexKey() hash = CryptoUtils.sha256(file).toHexKey()
} }
val tags = listOfNotNull( val tags = listOfNotNull(

View File

@ -56,6 +56,7 @@ class LiveActivitiesChatMessageEvent(
markAsSensitive: Boolean, markAsSensitive: Boolean,
zapRaiserAmount: Long?, zapRaiserAmount: Long?,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null,
onReady: (LiveActivitiesChatMessageEvent) -> Unit onReady: (LiveActivitiesChatMessageEvent) -> Unit
) { ) {
val content = message val content = message
@ -80,6 +81,11 @@ class LiveActivitiesChatMessageEvent(
geohash?.let { geohash?.let {
tags.addAll(geohashMipMap(it)) tags.addAll(geohashMipMap(it))
} }
nip94attachments?.let {
it.forEach {
tags.add(arrayOf("nip94", it.toJson()))
}
}
signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady)
} }

View File

@ -60,6 +60,7 @@ class PollNoteEvent(
markAsSensitive: Boolean, markAsSensitive: Boolean,
zapRaiserAmount: Long?, zapRaiserAmount: Long?,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null,
onReady: (PollNoteEvent) -> Unit onReady: (PollNoteEvent) -> Unit
) { ) {
val tags = mutableListOf<Array<String>>() val tags = mutableListOf<Array<String>>()
@ -99,6 +100,11 @@ class PollNoteEvent(
geohash?.let { geohash?.let {
tags.addAll(geohashMipMap(it)) tags.addAll(geohashMipMap(it))
} }
nip94attachments?.let {
it.forEach {
tags.add(arrayOf("nip94", it.toJson()))
}
}
signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady)
} }

View File

@ -39,6 +39,7 @@ class TextNoteEvent(
root: String?, root: String?,
directMentions: Set<HexKey>, directMentions: Set<HexKey>,
geohash: String? = null, geohash: String? = null,
nip94attachments: List<Event>? = null,
signer: NostrSigner, signer: NostrSigner,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
onReady: (TextNoteEvent) -> Unit onReady: (TextNoteEvent) -> Unit
@ -96,6 +97,11 @@ class TextNoteEvent(
geohash?.let { geohash?.let {
tags.addAll(geohashMipMap(it)) tags.addAll(geohashMipMap(it))
} }
nip94attachments?.let {
it.forEach {
tags.add(arrayOf("nip94", it.toJson()))
}
}
signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady)
} }