Adds support for encrypted media uploads on NIP-17 DMs
This commit is contained in:
@@ -27,13 +27,13 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
|
||||
@@ -67,7 +67,6 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
@@ -101,6 +100,7 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NIP17Factory
|
||||
import com.vitorpamplona.quartz.events.NIP17Group
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
@@ -1233,14 +1233,9 @@ class Account(
|
||||
return
|
||||
}
|
||||
|
||||
if (note.event is ChatMessageEvent) {
|
||||
val event = note.event as ChatMessageEvent
|
||||
val users =
|
||||
event
|
||||
.recipientsPubKey()
|
||||
.plus(event.pubKey)
|
||||
.toSet()
|
||||
.toList()
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is NIP17Group) {
|
||||
val users = noteEvent.groupMembers().toList()
|
||||
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrl.decode(reaction)
|
||||
@@ -2959,6 +2954,48 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNIP17EncryptedFile(
|
||||
url: String,
|
||||
toUsers: List<HexKey>,
|
||||
replyingTo: Note? = null,
|
||||
contentType: String?,
|
||||
algo: String,
|
||||
key: ByteArray,
|
||||
nonce: ByteArray? = null,
|
||||
originalHash: String? = null,
|
||||
hash: String? = null,
|
||||
size: Int? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
alt: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
|
||||
NIP17Factory().createEncryptedFileNIP17(
|
||||
url = url,
|
||||
to = toUsers,
|
||||
repliesToHex = repliesToHex,
|
||||
contentType = contentType,
|
||||
algo = algo,
|
||||
key = key,
|
||||
nonce = nonce,
|
||||
originalHash = originalHash,
|
||||
hash = hash,
|
||||
size = size,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
alt = alt,
|
||||
draftTag = null,
|
||||
signer = signer,
|
||||
) {
|
||||
broadcastPrivately(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNIP17PrivateMessage(
|
||||
message: String,
|
||||
toUsers: List<HexKey>,
|
||||
|
||||
@@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.events.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
@@ -625,6 +626,8 @@ object LocalCache {
|
||||
is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||
is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
is LnZapEvent ->
|
||||
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
|
||||
@@ -1621,7 +1624,50 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet()
|
||||
val recipientsHex = event.groupMembers()
|
||||
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
|
||||
|
||||
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
|
||||
val repliesTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
if (recipients.isNotEmpty()) {
|
||||
recipients.forEach {
|
||||
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
|
||||
|
||||
val authorGroup =
|
||||
if (groupMinusRecipient.isEmpty()) {
|
||||
// note to self
|
||||
ChatroomKey(persistentSetOf(it.pubkeyHex))
|
||||
} else {
|
||||
ChatroomKey(groupMinusRecipient.toImmutableSet())
|
||||
}
|
||||
|
||||
it.addMessage(authorGroup, note)
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: ChatMessageEncryptedFileHeaderEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val recipientsHex = event.groupMembers()
|
||||
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
|
||||
|
||||
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
@@ -2264,7 +2310,27 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
is ChatMessageEvent -> {
|
||||
val recipientsHex = draft.recipientsPubKey().plus(draftWrap.pubKey).toSet()
|
||||
val recipientsHex = draft.groupMembers()
|
||||
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
|
||||
|
||||
if (recipients.isNotEmpty()) {
|
||||
recipients.forEach {
|
||||
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
|
||||
|
||||
val authorGroup =
|
||||
if (groupMinusRecipient.isEmpty()) {
|
||||
// note to self
|
||||
ChatroomKey(persistentSetOf(it.pubkeyHex))
|
||||
} else {
|
||||
ChatroomKey(groupMinusRecipient.toImmutableSet())
|
||||
}
|
||||
|
||||
it.addMessage(authorGroup, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatMessageEncryptedFileHeaderEvent -> {
|
||||
val recipientsHex = draft.groupMembers()
|
||||
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
|
||||
|
||||
if (recipients.isNotEmpty()) {
|
||||
@@ -2332,6 +2398,26 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatMessageEncryptedFileHeaderEvent -> {
|
||||
val recipientsHex = draft.groupMembers()
|
||||
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
|
||||
|
||||
if (recipients.isNotEmpty()) {
|
||||
recipients.forEach {
|
||||
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
|
||||
|
||||
val authorGroup =
|
||||
if (groupMinusRecipient.isEmpty()) {
|
||||
// note to self
|
||||
ChatroomKey(persistentSetOf(it.pubkeyHex))
|
||||
} else {
|
||||
ChatroomKey(groupMinusRecipient.toImmutableSet())
|
||||
}
|
||||
|
||||
it.removeMessage(authorGroup, draftWrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChannelMessageEvent -> {
|
||||
draft.channel()?.let { channelId ->
|
||||
checkGetOrCreateChannel(channelId)?.let { channel ->
|
||||
@@ -2398,6 +2484,7 @@ object LocalCache {
|
||||
is ChannelMessageEvent -> consume(event, relay)
|
||||
is ChannelMetadataEvent -> consume(event)
|
||||
is ChannelMuteUserEvent -> consume(event)
|
||||
is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay)
|
||||
is ChatMessageEvent -> consume(event, relay)
|
||||
is ChatMessageRelayListEvent -> consume(event, relay)
|
||||
is ClassifiedsEvent -> consume(event, relay)
|
||||
|
||||
+49
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZa
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
@@ -121,6 +122,9 @@ class EventNotificationConsumer(
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
Log.d(TAG, "New ChatMessage to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
} else if (innerEvent is ChatMessageEncryptedFileHeaderEvent) {
|
||||
Log.d(TAG, "New ChatMessage File to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +198,51 @@ class EventNotificationConsumer(
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
event: ChatMessageEncryptedFileHeaderEvent,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
if (
|
||||
// old event being re-broadcasted
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
// don't display if it comes from me.
|
||||
event.pubKey != signer.pubKey
|
||||
) { // from the user
|
||||
Log.d(TAG, "Notifying")
|
||||
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
|
||||
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
val chatRoom = event.chatroomKey(signer.pubKey)
|
||||
|
||||
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
|
||||
|
||||
val isKnownRoom =
|
||||
(
|
||||
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
myUser.hasSentMessagesTo(chatRoom)
|
||||
)
|
||||
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub()
|
||||
|
||||
// TODO: Show Image on notification
|
||||
notificationManager()
|
||||
.sendDMNotification(
|
||||
event.id,
|
||||
content,
|
||||
user,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
event: ChatMessageEvent,
|
||||
signer: NostrSigner,
|
||||
|
||||
+18
-2
@@ -18,6 +18,22 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
class CombinedUploader
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class DefaultContentTypeInterceptor(
|
||||
private val userAgentHeader: String,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest: Request = chain.request()
|
||||
val requestWithUserAgent: Request =
|
||||
originalRequest
|
||||
.newBuilder()
|
||||
.header("User-Agent", userAgentHeader)
|
||||
.build()
|
||||
return chain.proceed(requestWithUserAgent)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.quartz.crypto.nip17.AESGCM
|
||||
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
|
||||
class EncryptedBlobInterceptor(
|
||||
val cache: EncryptionKeyCache,
|
||||
) : Interceptor {
|
||||
fun Response.decrypt(cipher: NostrCipher): Response {
|
||||
val body = peekBody(Long.MAX_VALUE)
|
||||
val decryptedBytes = cipher.decrypt(body.bytes())
|
||||
val newBody = decryptedBytes.toResponseBody(body.contentType())
|
||||
return newBuilder().body(newBody).build()
|
||||
}
|
||||
|
||||
fun Response.decryptOrNull(cipher: NostrCipher): Response? =
|
||||
try {
|
||||
decrypt(cipher)
|
||||
} catch (e: Exception) {
|
||||
Log.w("EncryptedBlobInterceptor", "Failed to decrypt", e)
|
||||
null
|
||||
}
|
||||
|
||||
private fun Response.decryptOrNullWithErrorCorrection(cipher: NostrCipher): Response? {
|
||||
return decryptOrNull(cipher) ?: return if (cipher is AESGCM) {
|
||||
decryptOrNull(cipher.copyUsingUTF8Nonce())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
|
||||
val cipher = cache.get(request.url.toString()) ?: return response
|
||||
|
||||
if (response.isSuccessful) {
|
||||
return response.decryptOrNullWithErrorCorrection(cipher) ?: response
|
||||
} else {
|
||||
// Log redirections to be able to use the cipher.
|
||||
response.header("Location")?.let {
|
||||
cache.add(it, cipher)
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
||||
|
||||
/**
|
||||
* Neigther ExoPlayer, nor Coil support passing key and nonce to the Interceptor via
|
||||
* Request.tag, which would be the right way to do this.
|
||||
*
|
||||
* This class serves as a key cache to decrypt the body of HTTP calls that need it.
|
||||
*/
|
||||
class EncryptionKeyCache {
|
||||
val cache = LruCache<String, NostrCipher>(100)
|
||||
|
||||
fun add(
|
||||
url: String?,
|
||||
cipher: NostrCipher,
|
||||
) {
|
||||
if (cache.get(url) == null) {
|
||||
cache.put(url, cipher)
|
||||
}
|
||||
}
|
||||
|
||||
fun get(url: String): NostrCipher? = cache.get(url)
|
||||
}
|
||||
+9
-64
@@ -22,18 +22,13 @@ package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
|
||||
object HttpClientManager {
|
||||
val rootClient =
|
||||
private val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
@@ -50,6 +45,8 @@ object HttpClientManager {
|
||||
|
||||
private var currentProxy: Proxy? = null
|
||||
|
||||
private val cache = EncryptionKeyCache()
|
||||
|
||||
fun setDefaultProxy(proxy: Proxy?) {
|
||||
if (currentProxy != proxy) {
|
||||
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
|
||||
@@ -96,67 +93,10 @@ object HttpClientManager {
|
||||
.writeTimeout(duration)
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.addNetworkInterceptor(LoggingInterceptor())
|
||||
.addNetworkInterceptor(EncryptedBlobInterceptor())
|
||||
.addNetworkInterceptor(EncryptedBlobInterceptor(cache))
|
||||
.build()
|
||||
}
|
||||
|
||||
class DefaultContentTypeInterceptor(
|
||||
private val userAgentHeader: String,
|
||||
) : Interceptor {
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest: Request = chain.request()
|
||||
val requestWithUserAgent: Request =
|
||||
originalRequest
|
||||
.newBuilder()
|
||||
.header("User-Agent", userAgentHeader)
|
||||
.build()
|
||||
return chain.proceed(requestWithUserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
class EncryptedBlobInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val cipher = chain.request().tag(NostrCipher::class)
|
||||
|
||||
println("AABBCC Cipher ${chain.request().tag(NostrCipher::class)}")
|
||||
|
||||
if (cipher != null) {
|
||||
val body = response.peekBody(Long.MAX_VALUE)
|
||||
val decryptedBytes = cipher.decrypt(body.bytes())
|
||||
val newBody = decryptedBytes.toResponseBody(body.contentType())
|
||||
return response.newBuilder().body(newBody).build()
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
class LoggingInterceptor : Interceptor {
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request: Request = chain.request()
|
||||
val t1 = System.nanoTime()
|
||||
val port =
|
||||
(
|
||||
chain
|
||||
.connection()
|
||||
?.route()
|
||||
?.proxy
|
||||
?.address() as? InetSocketAddress
|
||||
)?.port
|
||||
val response: Response = chain.proceed(request)
|
||||
val t2 = System.nanoTime()
|
||||
|
||||
Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms")
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentProxyPort(useProxy: Boolean): Int? =
|
||||
if (useProxy) {
|
||||
(currentProxy?.address() as? InetSocketAddress)?.port
|
||||
@@ -180,4 +120,9 @@ object HttpClientManager {
|
||||
fun setDefaultProxyOnPort(port: Int) {
|
||||
setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
|
||||
}
|
||||
|
||||
fun addCipherToCache(
|
||||
url: String,
|
||||
cipher: NostrCipher,
|
||||
) = cache.add(url, cipher)
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
class LoggingInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request: Request = chain.request()
|
||||
val t1 = System.nanoTime()
|
||||
val port =
|
||||
(
|
||||
chain
|
||||
.connection()
|
||||
?.route()
|
||||
?.proxy
|
||||
?.address() as? InetSocketAddress
|
||||
)?.port
|
||||
val response: Response = chain.proceed(request)
|
||||
val t2 = System.nanoTime()
|
||||
|
||||
Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms")
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -91,7 +91,7 @@ class MultiPlayerPlaybackManager(
|
||||
|
||||
val player =
|
||||
ExoPlayer.Builder(context).run {
|
||||
dataSourceFactory?.let { setMediaSourceFactory(it) }
|
||||
setMediaSourceFactory(dataSourceFactory)
|
||||
build()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import java.io.File
|
||||
|
||||
class EncryptFilesResult(
|
||||
val uri: Uri,
|
||||
val contentType: String?,
|
||||
val originalHash: String,
|
||||
val encryptedHash: String,
|
||||
val size: Long?,
|
||||
)
|
||||
|
||||
class EncryptFiles {
|
||||
fun encryptFile(
|
||||
context: Context,
|
||||
inputFile: Uri,
|
||||
cipher: NostrCipher,
|
||||
): EncryptFilesResult {
|
||||
val resolver = context.contentResolver
|
||||
|
||||
val encryptedFile = File.createTempFile("EncryptFiles", ".encrypted", context.getCacheDir())
|
||||
|
||||
resolver.openInputStream(inputFile)!!.use { inputStream ->
|
||||
val bytes = inputStream.readBytes()
|
||||
val originalHash = CryptoUtils.sha256(bytes).toHexKey()
|
||||
val encrypted = cipher.encrypt(bytes)
|
||||
val encryptedHash = CryptoUtils.sha256(encrypted).toHexKey()
|
||||
|
||||
encryptedFile.outputStream().use { outputStream ->
|
||||
outputStream.write(encrypted)
|
||||
}
|
||||
|
||||
return EncryptFilesResult(
|
||||
encryptedFile.toUri(),
|
||||
"application/octet-stream",
|
||||
originalHash,
|
||||
encryptedHash,
|
||||
encrypted.size.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
|
||||
import com.vitorpamplona.amethyst.service.Blurhash
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MultiOrchestrator(
|
||||
uris: List<SelectedMedia>,
|
||||
) {
|
||||
private var list: List<SelectedMediaProcessing> = uris.map { SelectedMediaProcessing(it) }
|
||||
|
||||
@Stable
|
||||
class Result(
|
||||
val allGood: Boolean,
|
||||
val successful: List<UploadingState.Finished>,
|
||||
val errors: List<UploadingState.Error>,
|
||||
)
|
||||
|
||||
fun first() = list.first()
|
||||
|
||||
suspend fun upload(
|
||||
scope: CoroutineScope,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: CompressorQuality,
|
||||
server: ServerName,
|
||||
account: Account,
|
||||
context: Context,
|
||||
): Result {
|
||||
val jobs =
|
||||
list.map { item ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
item.orchestrator.upload(
|
||||
item.media.uri,
|
||||
item.media.mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
mediaQuality,
|
||||
server,
|
||||
account,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
jobs.joinAll()
|
||||
|
||||
return computeFinalResults()
|
||||
}
|
||||
|
||||
suspend fun uploadEncrypted(
|
||||
scope: CoroutineScope,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: CompressorQuality,
|
||||
cipher: NostrCipher,
|
||||
server: ServerName,
|
||||
account: Account,
|
||||
context: Context,
|
||||
): Result {
|
||||
val jobs =
|
||||
list.map { item ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
item.orchestrator.uploadEncrypted(
|
||||
item.media.uri,
|
||||
item.media.mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
mediaQuality,
|
||||
cipher,
|
||||
server,
|
||||
account,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
jobs.joinAll()
|
||||
|
||||
return computeFinalResults()
|
||||
}
|
||||
|
||||
private fun computeFinalResults(): Result {
|
||||
val resultsByState =
|
||||
list.map {
|
||||
it.orchestrator.progressState.value
|
||||
}
|
||||
|
||||
val finished = resultsByState.filterIsInstance<UploadingState.Finished>()
|
||||
val errors = resultsByState.filterIsInstance<UploadingState.Error>()
|
||||
|
||||
return Result(finished.size == list.size, finished, errors)
|
||||
}
|
||||
|
||||
fun remove(selected: SelectedMediaProcessing) {
|
||||
list = list.filter { it != selected }
|
||||
}
|
||||
|
||||
fun size() = list.size
|
||||
|
||||
fun get(index: Int) = list.get(index)
|
||||
}
|
||||
+72
-23
@@ -18,22 +18,18 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState.UploadingFinalState
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressorResult
|
||||
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -64,8 +60,6 @@ sealed class UploadingState {
|
||||
}
|
||||
|
||||
class UploadOrchestrator {
|
||||
private val compressor = MediaCompressor()
|
||||
|
||||
val progress = MutableStateFlow(0.0)
|
||||
val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready)
|
||||
|
||||
@@ -79,7 +73,10 @@ class UploadOrchestrator {
|
||||
vararg params: String,
|
||||
) = UploadingState.Error(resId, params).also { updateState(0.0, it) }
|
||||
|
||||
fun finish(result: OrchestratorResult) = UploadingState.Finished(result).also { updateState(1.0, it) }
|
||||
fun finish(result: OrchestratorResult) =
|
||||
UploadingState
|
||||
.Finished(result)
|
||||
.also { updateState(1.0, it) }
|
||||
|
||||
fun updateState(
|
||||
newProgress: Double,
|
||||
@@ -92,6 +89,8 @@ class UploadOrchestrator {
|
||||
private fun uploadNIP95(
|
||||
fileUri: Uri,
|
||||
contentType: String?,
|
||||
originalContentType: String?,
|
||||
originalHash: String?,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
updateState(0.4, UploadingState.Uploading)
|
||||
@@ -117,7 +116,7 @@ class UploadOrchestrator {
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
return finish(OrchestratorResult.NIP95Result(it, bytes))
|
||||
return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
|
||||
},
|
||||
onFailure = {
|
||||
return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
|
||||
@@ -135,6 +134,8 @@ class UploadOrchestrator {
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
serverBaseUrl: String,
|
||||
contentTypeForResult: String?,
|
||||
originalHash: String?,
|
||||
account: Account,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
@@ -159,6 +160,8 @@ class UploadOrchestrator {
|
||||
verifyHeader(
|
||||
uploadResult = result,
|
||||
localContentType = contentType,
|
||||
originalContentType = contentTypeForResult,
|
||||
originalHash = originalHash,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -174,6 +177,8 @@ class UploadOrchestrator {
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
serverBaseUrl: String,
|
||||
contentTypeForResult: String?,
|
||||
originalHash: String?,
|
||||
account: Account,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
@@ -197,6 +202,8 @@ class UploadOrchestrator {
|
||||
uploadResult = result,
|
||||
localContentType = contentType,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
originalHash = originalHash,
|
||||
originalContentType = contentTypeForResult,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -207,6 +214,8 @@ class UploadOrchestrator {
|
||||
private suspend fun verifyHeader(
|
||||
uploadResult: MediaUploadResult,
|
||||
localContentType: String?,
|
||||
originalContentType: String?,
|
||||
originalHash: String?,
|
||||
forceProxy: (String) -> Boolean,
|
||||
): UploadingFinalState {
|
||||
if (uploadResult.url.isNullOrBlank()) {
|
||||
@@ -229,7 +238,16 @@ class UploadOrchestrator {
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
return finish(OrchestratorResult.ServerResult(it, uploadResult.url, uploadResult.magnet, uploadResult.sha256))
|
||||
return finish(
|
||||
OrchestratorResult.ServerResult(
|
||||
it,
|
||||
uploadResult.url,
|
||||
uploadResult.magnet,
|
||||
uploadResult.sha256,
|
||||
originalContentType,
|
||||
originalHash,
|
||||
),
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName)
|
||||
@@ -244,16 +262,32 @@ class UploadOrchestrator {
|
||||
class NIP95Result(
|
||||
val fileHeader: FileHeader,
|
||||
val bytes: ByteArray,
|
||||
val mimeTypeBeforeEncryption: String?,
|
||||
val hashBeforeEncryption: String?,
|
||||
) : OrchestratorResult()
|
||||
|
||||
class ServerResult(
|
||||
val fileHeader: FileHeader,
|
||||
val url: String,
|
||||
val magnet: String?,
|
||||
val originalHash: String?,
|
||||
val uploadedHash: String?,
|
||||
val mimeTypeBeforeEncryption: String?,
|
||||
val hashBeforeEncryption: String?,
|
||||
) : OrchestratorResult()
|
||||
}
|
||||
|
||||
suspend fun compressIfNeeded(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
compressionQuality: CompressorQuality,
|
||||
context: Context,
|
||||
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) {
|
||||
updateState(0.02, UploadingState.Compressing)
|
||||
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext)
|
||||
} else {
|
||||
MediaCompressorResult(uri, mimeType, null)
|
||||
}
|
||||
|
||||
suspend fun upload(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
@@ -264,18 +298,33 @@ class UploadOrchestrator {
|
||||
account: Account,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
val result =
|
||||
if (compressionQuality != CompressorQuality.UNCOMPRESSED) {
|
||||
updateState(0.02, UploadingState.Compressing)
|
||||
compressor.compress(uri, mimeType, compressionQuality, context.applicationContext)
|
||||
} else {
|
||||
MediaCompressorResult(uri, mimeType, null)
|
||||
}
|
||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context)
|
||||
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(result.uri, result.contentType, context)
|
||||
ServerType.NIP96 -> uploadNIP96(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, account, context)
|
||||
ServerType.Blossom -> uploadBlossom(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, account, context)
|
||||
ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context)
|
||||
ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, sensitiveContent, server.baseUrl, null, null, account, context)
|
||||
ServerType.Blossom -> uploadBlossom(compressed.uri, compressed.contentType, compressed.size, alt, sensitiveContent, server.baseUrl, null, null, account, context)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadEncrypted(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
compressionQuality: CompressorQuality,
|
||||
encrypt: NostrCipher,
|
||||
server: ServerName,
|
||||
account: Account,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context)
|
||||
val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt)
|
||||
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
|
||||
ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, sensitiveContent, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context)
|
||||
ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, sensitiveContent, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -175,16 +175,18 @@ class BlossomUploader {
|
||||
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
if (errorMessage != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage))
|
||||
} else if (explanation != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation)))
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://")
|
||||
|
||||
suspend fun delete(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
|
||||
+9
-7
@@ -185,7 +185,7 @@ class Nip96Uploader {
|
||||
} else if (result.status == "success" && result.nip94Event != null) {
|
||||
return convertToMediaResult(result.nip94Event)
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -207,16 +207,18 @@ class Nip96Uploader {
|
||||
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
if (errorMessage != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage))
|
||||
} else if (explanation != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation)))
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://")
|
||||
|
||||
fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
@@ -225,9 +227,9 @@ class Nip96Uploader {
|
||||
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val originalHash =
|
||||
val hash =
|
||||
nip96.tags
|
||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
||||
?.firstOrNull { it.size > 1 && it[0] == "x" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val dim =
|
||||
@@ -245,7 +247,7 @@ class Nip96Uploader {
|
||||
return MediaUploadResult(
|
||||
url = imageUrl,
|
||||
type = remoteMimeType,
|
||||
sha256 = originalHash,
|
||||
sha256 = hash,
|
||||
dimension = dim,
|
||||
magnet = magnet,
|
||||
)
|
||||
|
||||
@@ -114,7 +114,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -328,13 +327,13 @@ fun EditPostView(
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.mediaToUpload.isNotEmpty()) {
|
||||
postViewModel.multiOrchestrator?.let {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
postViewModel.mediaToUpload,
|
||||
it,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||
@@ -343,7 +342,7 @@ fun EditPostView(
|
||||
}
|
||||
},
|
||||
onDelete = postViewModel::deleteMediaToUpload,
|
||||
onCancel = { postViewModel.mediaToUpload = persistentListOf() },
|
||||
onCancel = { postViewModel.multiOrchestrator = null },
|
||||
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -36,23 +37,21 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@@ -77,7 +76,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
|
||||
// Images and Videos
|
||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
@@ -102,7 +101,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
this.account = accountViewModel.account
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
mediaToUpload = persistentListOf()
|
||||
multiOrchestrator = null
|
||||
|
||||
message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
|
||||
urlPreview = findUrlInMessage()
|
||||
@@ -156,38 +155,27 @@ open class EditPostViewModel : ViewModel() {
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
val myAccount = account ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
val myAccount = account ?: return@launch
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploadingImage = true
|
||||
|
||||
val jobs =
|
||||
mediaToUpload.map { myGalleryUri ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
myGalleryUri.orchestrator.upload(
|
||||
myGalleryUri.media.uri,
|
||||
myGalleryUri.media.mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
viewModelScope,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
|
||||
jobs.joinAll()
|
||||
|
||||
val allGood =
|
||||
mediaToUpload.mapNotNull {
|
||||
it.orchestrator.progressState.value as? UploadingState.Finished
|
||||
}
|
||||
|
||||
if (allGood.size == mediaToUpload.size) {
|
||||
allGood.forEach {
|
||||
if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 ->
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
account?.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
@@ -197,33 +185,36 @@ open class EditPostViewModel : ViewModel() {
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(it.result.url)
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(it.result.fileHeader.hash)
|
||||
size(it.result.fileHeader.size)
|
||||
it.result.fileHeader.mimeType
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
it.result.fileHeader.dim
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
it.result.fileHeader.blurHash
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
it.result.magnet?.let { magnet(it) }
|
||||
it.result.originalHash?.let { originalHash(it) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
// TODO: Support Reasons on images
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
message = message.insertUrlAtCursor(it.result.url)
|
||||
message = message.insertUrlAtCursor(state.result.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
}
|
||||
|
||||
mediaToUpload = persistentListOf()
|
||||
this@EditPostViewModel.multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
@@ -236,7 +227,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
|
||||
editedFromNote = null
|
||||
|
||||
mediaToUpload = persistentListOf()
|
||||
multiOrchestrator = null
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
|
||||
@@ -304,13 +295,13 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && mediaToUpload.isEmpty()
|
||||
fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && multiOrchestrator == null
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
||||
multiOrchestrator = MultiOrchestrator(uris)
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,19 +28,19 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -59,7 +59,7 @@ open class NewMediaModel : ViewModel() {
|
||||
var sensitiveContent by mutableStateOf(false)
|
||||
|
||||
// Images and Videos
|
||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||
@@ -71,7 +71,7 @@ open class NewMediaModel : ViewModel() {
|
||||
) {
|
||||
this.caption = ""
|
||||
this.account = account
|
||||
this.mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
||||
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||
this.selectedServer = defaultServer()
|
||||
}
|
||||
|
||||
@@ -83,46 +83,37 @@ open class NewMediaModel : ViewModel() {
|
||||
fun upload(
|
||||
context: Context,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
val myAccount = account ?: return
|
||||
if (relayList.isEmpty()) return
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
val myAccount = account ?: return@launch
|
||||
if (relayList.isEmpty()) return@launch
|
||||
val serverToUse = selectedServer ?: return@launch
|
||||
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
isUploadingImage = true
|
||||
|
||||
val jobs =
|
||||
mediaToUpload.map { myGalleryUri ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
myGalleryUri.orchestrator.upload(
|
||||
myGalleryUri.media.uri,
|
||||
myGalleryUri.media.mimeType,
|
||||
caption,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
|
||||
serverToUse,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
viewModelScope,
|
||||
caption,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
|
||||
serverToUse,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
|
||||
jobs.joinAll()
|
||||
|
||||
val allGood =
|
||||
mediaToUpload.mapNotNull {
|
||||
it.orchestrator.progressState.value as? UploadingState.Finished
|
||||
}
|
||||
|
||||
if (allGood.size == mediaToUpload.size) {
|
||||
if (results.allGood) {
|
||||
// It all finished successfully
|
||||
val nip95s =
|
||||
allGood.mapNotNull {
|
||||
results.successful.mapNotNull {
|
||||
it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result
|
||||
}
|
||||
|
||||
val videosAndOthers =
|
||||
allGood.mapNotNull {
|
||||
results.successful.mapNotNull {
|
||||
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||
if (map != null && !isImage(map.url, map.fileHeader.mimeType)) {
|
||||
map
|
||||
@@ -132,7 +123,7 @@ open class NewMediaModel : ViewModel() {
|
||||
}
|
||||
|
||||
val imageUrls =
|
||||
allGood
|
||||
results.successful
|
||||
.mapNotNull {
|
||||
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||
if (map != null && isImage(map.url, map.fileHeader.mimeType)) {
|
||||
@@ -169,7 +160,7 @@ open class NewMediaModel : ViewModel() {
|
||||
it.fileHeader,
|
||||
caption,
|
||||
sensitiveContent,
|
||||
it.originalHash,
|
||||
it.uploadedHash,
|
||||
relayList,
|
||||
) {
|
||||
continuation.resume(true)
|
||||
@@ -203,22 +194,26 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
onceUploaded()
|
||||
cancelModel()
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancelModel() {
|
||||
mediaToUpload = persistentListOf()
|
||||
multiOrchestrator = null
|
||||
isUploadingImage = false
|
||||
caption = ""
|
||||
selectedServer = defaultServer()
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
|
||||
multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
fun canPost(): Boolean = !isUploadingImage && mediaToUpload.isNotEmpty() && selectedServer != null
|
||||
fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null && selectedServer != null
|
||||
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
|
||||
|
||||
@@ -141,8 +141,7 @@ fun NewMediaView(
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context, relayList)
|
||||
// accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
postViewModel.upload(context, relayList, onError = accountViewModel::toast)
|
||||
postViewModel.selectedServer?.let {
|
||||
if (it.type != ServerType.NIP95) {
|
||||
account.settings.changeDefaultFileServer(it)
|
||||
@@ -218,11 +217,13 @@ fun ImageVideoPost(
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
ShowImageUploadGallery(
|
||||
postViewModel.mediaToUpload,
|
||||
postViewModel::deleteMediaToUpload,
|
||||
accountViewModel,
|
||||
)
|
||||
postViewModel.multiOrchestrator?.let {
|
||||
ShowImageUploadGallery(
|
||||
it,
|
||||
postViewModel::deleteMediaToUpload,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.add_caption)) },
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -42,15 +43,17 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.crypto.nip17.AESGCM
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
@@ -59,7 +62,6 @@ import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
@@ -68,6 +70,7 @@ import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.NIP17Group
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.RootScope
|
||||
@@ -77,13 +80,10 @@ import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.events.findURLs
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
@@ -127,7 +127,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
var subject by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// Images and Videos
|
||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Polls
|
||||
var canUsePoll by mutableStateOf(false)
|
||||
@@ -247,7 +247,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
mediaToUpload = persistentListOf()
|
||||
multiOrchestrator = null
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||
@@ -331,7 +331,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
mediaToUpload = persistentListOf()
|
||||
multiOrchestrator = null
|
||||
|
||||
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
|
||||
forwardZapTo = Split()
|
||||
@@ -364,7 +364,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
note
|
||||
}
|
||||
|
||||
if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) {
|
||||
if (draftEvent !is PrivateDmEvent && draftEvent !is NIP17Group) {
|
||||
pTags =
|
||||
draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map {
|
||||
LocalCache.getOrCreateUser(it[1])
|
||||
@@ -459,7 +459,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
.firstOrNull()
|
||||
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
|
||||
|
||||
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent
|
||||
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is NIP17Group
|
||||
|
||||
draftEvent.subject()?.let {
|
||||
subject = TextFieldValue()
|
||||
@@ -474,13 +474,13 @@ open class NewPostViewModel : ViewModel() {
|
||||
TextFieldValue(draftEvent.content())
|
||||
}
|
||||
|
||||
requiresNIP17 = draftEvent is ChatMessageEvent
|
||||
nip17 = draftEvent is ChatMessageEvent
|
||||
requiresNIP17 = draftEvent is NIP17Group
|
||||
nip17 = draftEvent is NIP17Group
|
||||
|
||||
if (draftEvent is ChatMessageEvent) {
|
||||
if (draftEvent is NIP17Group) {
|
||||
toUsers =
|
||||
TextFieldValue(
|
||||
draftEvent.recipientsPubKey().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -627,18 +627,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (originalNote?.event is ChatMessageEvent) {
|
||||
val receivers =
|
||||
(originalNote?.event as ChatMessageEvent)
|
||||
.recipientsPubKey()
|
||||
.plus(originalNote?.author?.pubkeyHex)
|
||||
.filterNotNull()
|
||||
.toSet()
|
||||
.toList()
|
||||
|
||||
} else if (originalNote?.event is NIP17Group) {
|
||||
account?.sendNIP17PrivateMessage(
|
||||
message = tagger.message,
|
||||
toUsers = receivers,
|
||||
toUsers = (originalNote?.event as NIP17Group).groupMembers().toList(),
|
||||
subject = subject.text.ifBlank { null },
|
||||
replyingTo = originalNote!!,
|
||||
mentions = tagger.pTags,
|
||||
@@ -865,6 +857,70 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadAsSeparatePrivateEvent(
|
||||
toUsers: Set<HexKey>,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: Int,
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
val myAccount = account ?: return
|
||||
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
isUploadingImage = true
|
||||
|
||||
val cipher = AESGCM()
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
val results =
|
||||
myMultiOrchestrator.uploadEncrypted(
|
||||
viewModelScope,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
cipher,
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
account?.sendNIP17EncryptedFile(
|
||||
url = state.result.url,
|
||||
toUsers = toUsers.toList(),
|
||||
replyingTo = originalNote,
|
||||
contentType = state.result.mimeTypeBeforeEncryption,
|
||||
algo = cipher.name(),
|
||||
key = cipher.keyBytes,
|
||||
nonce = cipher.nonce,
|
||||
originalHash = state.result.hashBeforeEncryption,
|
||||
hash = state.result.fileHeader.hash,
|
||||
size = state.result.fileHeader.size,
|
||||
dimensions = state.result.fileHeader.dim,
|
||||
blurhash =
|
||||
state.result.fileHeader.blurHash
|
||||
?.blurhash,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
@@ -874,36 +930,26 @@ open class NewPostViewModel : ViewModel() {
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
val myAccount = account ?: return
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val myAccount = account ?: return@launch
|
||||
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
viewModelScope.launch {
|
||||
isUploadingImage = true
|
||||
|
||||
val jobs =
|
||||
mediaToUpload.map { myGalleryUri ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
myGalleryUri.orchestrator.upload(
|
||||
myGalleryUri.media.uri,
|
||||
myGalleryUri.media.mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
viewModelScope,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
|
||||
jobs.joinAll()
|
||||
|
||||
val allGood =
|
||||
mediaToUpload.mapNotNull {
|
||||
it.orchestrator.progressState.value as? UploadingState.Finished
|
||||
}
|
||||
|
||||
if (allGood.size == mediaToUpload.size) {
|
||||
allGood.forEach {
|
||||
if (results.allGood) {
|
||||
results.successful.forEach {
|
||||
if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
@@ -928,7 +974,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
it.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
it.result.magnet?.let { magnet(it) }
|
||||
it.result.originalHash?.let { originalHash(it) }
|
||||
it.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
// TODO: Support Reasons on images
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
@@ -941,7 +987,11 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
mediaToUpload = persistentListOf()
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
@@ -955,7 +1005,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
|
||||
forkedFromNote = null
|
||||
|
||||
mediaToUpload = persistentListOf()
|
||||
multiOrchestrator = null
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
pTags = null
|
||||
@@ -1004,7 +1054,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
|
||||
@@ -1170,14 +1220,14 @@ open class NewPostViewModel : ViewModel() {
|
||||
!category.text.isNullOrBlank()
|
||||
)
|
||||
) &&
|
||||
mediaToUpload.isEmpty()
|
||||
multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
||||
multiOrchestrator = MultiOrchestrator(uris)
|
||||
}
|
||||
|
||||
fun locationFlow(): StateFlow<LocationState.LocationResult> {
|
||||
|
||||
+2
-2
@@ -28,12 +28,12 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.events.GitHubIdentity
|
||||
import com.vitorpamplona.quartz.events.MastodonIdentity
|
||||
|
||||
+2
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
|
||||
class SelectedMediaProcessing(
|
||||
val media: SelectedMedia,
|
||||
val orchestrator: UploadOrchestrator = UploadOrchestrator(),
|
||||
|
||||
+6
-4
@@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.note.CloseIcon
|
||||
@@ -67,19 +70,18 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ShowImageUploadGallery(
|
||||
list: ImmutableList<SelectedMediaProcessing>,
|
||||
list: MultiOrchestrator,
|
||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
AutoNonlazyGrid(list.size) {
|
||||
ShowImageUploadItem(list[it], onDelete, accountViewModel)
|
||||
AutoNonlazyGrid(list.size()) {
|
||||
ShowImageUploadItem(list.get(it), onDelete, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
|
||||
@@ -158,6 +159,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
@@ -671,6 +673,18 @@ private fun RenderNoteRow(
|
||||
nav,
|
||||
)
|
||||
}
|
||||
is ChatMessageEncryptedFileHeaderEvent -> {
|
||||
RenderChatMessageEncryptedFile(
|
||||
baseNote,
|
||||
makeItShort,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
is ClassifiedsEvent -> {
|
||||
RenderClassifieds(
|
||||
noteEvent,
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomHeader
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.crypto.nip17.AESGCM
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun RenderChatMessageEncryptedFile(
|
||||
note: Note,
|
||||
makeItShort: Boolean,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val userRoom by
|
||||
remember(note) {
|
||||
derivedStateOf {
|
||||
(note.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
userRoom?.let {
|
||||
if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) {
|
||||
ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) {
|
||||
routeFor(note, accountViewModel.userProfile())?.let {
|
||||
nav.nav(it)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
RenderEncryptedFile(note, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderEncryptedFile(
|
||||
note: Note,
|
||||
backgroundBubbleColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? ChatMessageEncryptedFileHeaderEvent ?: return
|
||||
|
||||
val algo = noteEvent.algo()
|
||||
val key = noteEvent.key()
|
||||
val nonce = noteEvent.nonce()
|
||||
|
||||
if (algo == AESGCM.NAME && key != null && nonce != null) {
|
||||
HttpClientManager.addCipherToCache(noteEvent.content, AESGCM(key, nonce))
|
||||
|
||||
val content by remember(noteEvent) {
|
||||
val isImage = noteEvent.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content)
|
||||
val mimeType = noteEvent.mimeType()
|
||||
|
||||
mutableStateOf<BaseMediaContent>(
|
||||
if (isImage) {
|
||||
EncryptedMediaUrlImage(
|
||||
url = noteEvent.content,
|
||||
description = noteEvent.alt(),
|
||||
hash = noteEvent.originalHash(),
|
||||
blurhash = noteEvent.blurhash(),
|
||||
dim = noteEvent.dimensions(),
|
||||
uri = noteEvent.toNostrUri(),
|
||||
mimeType = mimeType,
|
||||
encryptionAlgo = algo,
|
||||
encryptionKey = key,
|
||||
encryptionNonce = nonce,
|
||||
)
|
||||
} else {
|
||||
EncryptedMediaUrlVideo(
|
||||
url = noteEvent.content,
|
||||
description = noteEvent.alt(),
|
||||
hash = noteEvent.originalHash(),
|
||||
blurhash = noteEvent.blurhash(),
|
||||
dim = noteEvent.dimensions(),
|
||||
uri = note.toNostrUri(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
encryptionAlgo = algo,
|
||||
encryptionKey = key,
|
||||
encryptionNonce = nonce,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ZoomableContentView(
|
||||
content,
|
||||
persistentListOf(content),
|
||||
roundedCorner = true,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel,
|
||||
)
|
||||
} else {
|
||||
TranslatableRichTextViewer(
|
||||
content = stringRes(id = R.string.could_not_decrypt_the_message),
|
||||
canPreview = true,
|
||||
quotesLeft = 0,
|
||||
modifier = Modifier,
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundBubbleColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -128,6 +128,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPollOption
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
@@ -506,13 +507,13 @@ fun NewPostScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.mediaToUpload.isNotEmpty()) {
|
||||
postViewModel.multiOrchestrator?.let {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
postViewModel.mediaToUpload,
|
||||
it,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||
@@ -521,7 +522,7 @@ fun NewPostScreen(
|
||||
}
|
||||
},
|
||||
onDelete = postViewModel::deleteMediaToUpload,
|
||||
onCancel = { postViewModel.mediaToUpload = persistentListOf() },
|
||||
onCancel = { postViewModel.multiOrchestrator = null },
|
||||
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
@@ -1666,7 +1667,7 @@ fun CreateButton(
|
||||
|
||||
@Composable
|
||||
fun ImageVideoDescription(
|
||||
uris: ImmutableList<SelectedMediaProcessing>,
|
||||
uris: MultiOrchestrator,
|
||||
defaultServer: ServerName,
|
||||
onAdd: (String, ServerName, Boolean, Int) -> Unit,
|
||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||
@@ -1727,7 +1728,7 @@ fun ImageVideoDescription(
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
val text =
|
||||
if (uris.size == 1) {
|
||||
if (uris.size() == 1) {
|
||||
if (uris.first().media.isImage() == true) {
|
||||
R.string.content_description_add_image
|
||||
} else {
|
||||
|
||||
+3
-3
@@ -110,14 +110,14 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
@@ -395,7 +395,7 @@ private suspend fun innerSendPost(
|
||||
tagger.run()
|
||||
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() }
|
||||
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url in urls.toSet() }
|
||||
|
||||
if (channel is PublicChatChannel) {
|
||||
accountViewModel.account.sendChannelMessage(
|
||||
|
||||
+13
-10
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.WatchUserFollows
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderEncryptedFile
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier
|
||||
@@ -102,10 +103,12 @@ import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.NIP17Group
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
|
||||
@@ -174,7 +177,7 @@ fun NormalChatNote(
|
||||
false // never shows the user's pictures
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
false // one-on-one, never shows it.
|
||||
} else if (noteEvent is ChatMessageEvent) {
|
||||
} else if (noteEvent is ChatroomKeyable) {
|
||||
// only shows in a group chat.
|
||||
noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1
|
||||
} else {
|
||||
@@ -581,6 +584,13 @@ private fun NoteRow(
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
is ChatMessageEncryptedFileHeaderEvent ->
|
||||
RenderEncryptedFile(
|
||||
note,
|
||||
backgroundBubbleColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
else ->
|
||||
RenderRegularTextNote(
|
||||
note,
|
||||
@@ -631,16 +641,9 @@ private fun RenderDraftEvent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConstrainedStatusRow(
|
||||
firstColumn: @Composable () -> Unit,
|
||||
secondColumn: @Composable () -> Unit,
|
||||
) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncognitoBadge(baseNote: Note) {
|
||||
if (baseNote.event is ChatMessageEvent) {
|
||||
if (baseNote.event is NIP17Group) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.incognito),
|
||||
null,
|
||||
|
||||
+34
-26
@@ -85,12 +85,13 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
@@ -121,9 +122,10 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.NIP17Group
|
||||
import com.vitorpamplona.quartz.events.findURLs
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -482,18 +484,34 @@ fun ChatroomScreen(
|
||||
}
|
||||
|
||||
// LAST ROW
|
||||
PrivateMessageEditFieldRow(newPostModel, accountViewModel) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
innerSendPost(newPostModel, room, replyTo, accountViewModel, null)
|
||||
PrivateMessageEditFieldRow(
|
||||
newPostModel,
|
||||
accountViewModel,
|
||||
onSendNewMessage = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
innerSendPost(newPostModel, room, replyTo, accountViewModel, null)
|
||||
|
||||
accountViewModel.deleteDraft(newPostModel.draftTag)
|
||||
accountViewModel.deleteDraft(newPostModel.draftTag)
|
||||
|
||||
newPostModel.message = TextFieldValue("")
|
||||
newPostModel.message = TextFieldValue("")
|
||||
|
||||
replyTo.value = null
|
||||
feedViewModel.sendToTop()
|
||||
}
|
||||
}
|
||||
replyTo.value = null
|
||||
feedViewModel.sendToTop()
|
||||
}
|
||||
},
|
||||
onSendNewMedia = {
|
||||
newPostModel.selectImage(it)
|
||||
newPostModel.uploadAsSeparatePrivateEvent(
|
||||
toUsers = room.users,
|
||||
alt = null,
|
||||
sensitiveContent = false,
|
||||
mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM),
|
||||
server = accountViewModel.account.settings.defaultFileServer,
|
||||
onError = accountViewModel::toast,
|
||||
context = context,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +525,7 @@ private fun innerSendPost(
|
||||
val urls = findURLs(newPostModel.message.text)
|
||||
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() }
|
||||
|
||||
if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) {
|
||||
if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) {
|
||||
accountViewModel.account.sendNIP17PrivateMessage(
|
||||
message = newPostModel.message.text,
|
||||
toUsers = room.users.toList(),
|
||||
@@ -535,6 +553,7 @@ fun PrivateMessageEditFieldRow(
|
||||
channelScreenModel: NewPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onSendNewMessage: () -> Unit,
|
||||
onSendNewMedia: (ImmutableList<SelectedMedia>) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = EditFieldModifier,
|
||||
@@ -579,19 +598,8 @@ fun PrivateMessageEditFieldRow(
|
||||
Modifier
|
||||
.size(30.dp)
|
||||
.padding(start = 2.dp),
|
||||
) {
|
||||
channelScreenModel.selectImage(it)
|
||||
channelScreenModel.upload(
|
||||
alt = null,
|
||||
sensitiveContent = false,
|
||||
// use MEDIUM quality
|
||||
mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM),
|
||||
isPrivate = true,
|
||||
server = accountViewModel.account.settings.defaultFileServer,
|
||||
onError = accountViewModel::toast,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
onImageChosen = onSendNewMedia,
|
||||
)
|
||||
|
||||
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
+2
-2
@@ -41,9 +41,9 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.NIP17Group
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
@@ -285,7 +285,7 @@ class CardFeedContentState(
|
||||
it.event !is GenericRepostEvent &&
|
||||
it.event !is LnZapEvent
|
||||
}.map {
|
||||
if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) {
|
||||
if (it.event is PrivateDmEvent || it.event is NIP17Group) {
|
||||
MessageSetCard(it)
|
||||
} else if (it.event is BadgeAwardEvent) {
|
||||
BadgeCard(it)
|
||||
|
||||
@@ -884,6 +884,7 @@
|
||||
<string name="could_not_download_from_the_server">Could not download uploaded media from the server</string>
|
||||
<string name="could_not_check_downloaded_file">Could not check downloaded file after upload: %1$s</string>
|
||||
<string name="could_not_prepare_local_file_to_upload">Could not prepare local file to upload: %1$s</string>
|
||||
<string name="failed_to_upload_to_server_with_message">Failed to upload to %1$s: %2$s</string>
|
||||
<string name="failed_to_upload_with_message">Failed to upload: %1$s</string>
|
||||
<string name="failed_to_delete_with_message">Failed to delete: %1$s</string>
|
||||
<string name="media_too_big_for_nip95">Media is too big for NIP-95</string>
|
||||
|
||||
@@ -24,6 +24,8 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Looper
|
||||
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
|
||||
import id.zelory.compressor.Compressor
|
||||
import io.mockk.MockKAnnotations
|
||||
|
||||
@@ -133,6 +133,7 @@ class Relay(
|
||||
lastConnectTentative = TimeUtils.now()
|
||||
|
||||
socket = socketBuilder.build(url, false, RelayListener(onConnected))
|
||||
socket?.connect()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.ammolite.sockets
|
||||
|
||||
interface WebSocket {
|
||||
fun connect()
|
||||
|
||||
fun cancel()
|
||||
|
||||
fun send(msg: String): Boolean
|
||||
|
||||
+33
-2
@@ -43,7 +43,7 @@ abstract class MediaUrlContent(
|
||||
) : BaseMediaContent(description, dim, blurhash)
|
||||
|
||||
@Immutable
|
||||
class MediaUrlImage(
|
||||
open class MediaUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
@@ -54,8 +54,22 @@ class MediaUrlImage(
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
|
||||
class EncryptedMediaUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
blurhash: String? = null,
|
||||
dim: Dimension? = null,
|
||||
uri: String? = null,
|
||||
contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
val encryptionAlgo: String,
|
||||
val encryptionKey: ByteArray,
|
||||
val encryptionNonce: ByteArray,
|
||||
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
|
||||
|
||||
@Immutable
|
||||
class MediaUrlVideo(
|
||||
open class MediaUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
@@ -68,6 +82,23 @@ class MediaUrlVideo(
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
|
||||
@Immutable
|
||||
class EncryptedMediaUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
dim: Dimension? = null,
|
||||
uri: String? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
blurhash: String? = null,
|
||||
contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
val encryptionAlgo: String,
|
||||
val encryptionKey: ByteArray,
|
||||
val encryptionNonce: ByteArray,
|
||||
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType)
|
||||
|
||||
@Immutable
|
||||
abstract class MediaPreloadedContent(
|
||||
val localFile: File?,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.crypto.nip17
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils.decrypt
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AESGCMTest {
|
||||
val decryptionNonce = "01e77c94bd5aba3e3cbb69594e7ba07c"
|
||||
val decryptionKey = "c128ecffab90ee7810e3df08e7fb2cc39a8d40f24201f48b2b36e23b34ac50ee"
|
||||
|
||||
val cipher = AESGCM(decryptionKey.hexToByteArray(), decryptionNonce.toByteArray(Charsets.UTF_8))
|
||||
|
||||
@Test
|
||||
fun encryptDecrypt() {
|
||||
val encrypted = cipher.encrypt("Testing".toByteArray(Charsets.UTF_8))
|
||||
val decrypted = cipher.decrypt(encrypted)
|
||||
|
||||
assertEquals("Testing", String(decrypted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageTest() {
|
||||
val image =
|
||||
getInstrumentation().context.assets.open("ovxxk2vz.jpg").use {
|
||||
it.readAllBytes()
|
||||
}
|
||||
|
||||
val decrypted = cipher.decrypt(image)
|
||||
|
||||
assertEquals(44201, decrypted.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.crypto.nip17
|
||||
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
interface NostrCipher {
|
||||
fun name(): String
|
||||
|
||||
fun encrypt(bytesToEncrypt: ByteArray): ByteArray
|
||||
|
||||
fun decrypt(bytesToDecrypt: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
class AESGCM(
|
||||
val keyBytes: ByteArray = CryptoUtils.random(32),
|
||||
val nonce: ByteArray = CryptoUtils.random(16),
|
||||
) : NostrCipher {
|
||||
private fun newCipher() = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
|
||||
private fun keySpec() = SecretKeySpec(keyBytes, "AES")
|
||||
|
||||
private fun param() = GCMParameterSpec(128, nonce)
|
||||
|
||||
override fun name() = NAME
|
||||
|
||||
fun copyUsingUTF8Nonce(): AESGCM =
|
||||
AESGCM(
|
||||
keyBytes,
|
||||
nonce.toHexKey().toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
|
||||
override fun encrypt(bytesToEncrypt: ByteArray): ByteArray =
|
||||
with(newCipher()) {
|
||||
init(Cipher.ENCRYPT_MODE, keySpec(), param())
|
||||
doFinal(bytesToEncrypt)
|
||||
}
|
||||
|
||||
override fun decrypt(bytesToDecrypt: ByteArray): ByteArray =
|
||||
with(newCipher()) {
|
||||
init(Cipher.DECRYPT_MODE, keySpec(), param())
|
||||
doFinal(bytesToDecrypt)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "aes-gcm"
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.events
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@Immutable
|
||||
class ChatMessageEncryptedFileHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
ChatroomKeyable,
|
||||
NIP17Group {
|
||||
/** Recipients intended to receive this conversation */
|
||||
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
|
||||
|
||||
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
|
||||
|
||||
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun talkingWith(oneSideHex: String): Set<HexKey> {
|
||||
val listedPubKeys = recipientsPubKey()
|
||||
|
||||
val result =
|
||||
if (pubKey == oneSideHex) {
|
||||
listedPubKeys.toSet().minus(oneSideHex)
|
||||
} else {
|
||||
listedPubKeys.plus(pubKey).toSet().minus(oneSideHex)
|
||||
}
|
||||
|
||||
if (result.isEmpty()) {
|
||||
// talking to myself
|
||||
return setOf(pubKey)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
|
||||
|
||||
fun url() = content
|
||||
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
|
||||
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
|
||||
|
||||
fun algo() = tags.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_ALGORITHM }?.get(1)
|
||||
|
||||
fun key() =
|
||||
tags
|
||||
.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_KEY }
|
||||
?.get(1)
|
||||
?.runCatching { this.hexToByteArray() }
|
||||
?.getOrNull()
|
||||
|
||||
fun nonce() =
|
||||
tags
|
||||
.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_NONCE }
|
||||
?.get(1)
|
||||
?.runCatching { this.hexToByteArray() }
|
||||
?.getOrNull()
|
||||
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
|
||||
fun originalHash() = tags.firstOrNull { it.size > 1 && it[0] == ORIGINAL_HASH }?.get(1)
|
||||
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
|
||||
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
|
||||
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val KIND = 15
|
||||
const val ALT_DESCRIPTION = "Encrypted file in chat"
|
||||
|
||||
const val MIME_TYPE = "file-type"
|
||||
|
||||
const val ENCRYPTION_ALGORITHM = "encryption-algorithm"
|
||||
const val ENCRYPTION_KEY = "decryption-key"
|
||||
const val ENCRYPTION_NONCE = "decryption-nonce"
|
||||
|
||||
const val FILE_SIZE = "size"
|
||||
const val DIMENSION = "dim"
|
||||
const val BLUR_HASH = "blurhash"
|
||||
const val HASH = "x"
|
||||
const val ORIGINAL_HASH = "ox"
|
||||
|
||||
const val ALT = "alt"
|
||||
|
||||
fun buildTags(
|
||||
to: List<HexKey>,
|
||||
repliesTo: List<HexKey>? = null,
|
||||
contentType: String?,
|
||||
algo: String,
|
||||
key: ByteArray,
|
||||
nonce: ByteArray? = null,
|
||||
originalHash: String? = null,
|
||||
hash: String? = null,
|
||||
size: Int? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
alt: String?,
|
||||
): Array<Array<String>> {
|
||||
val repliesHex = repliesTo?.map { arrayOf("e", it) } ?: emptyList()
|
||||
|
||||
return (
|
||||
to.map { arrayOf("p", it) } + repliesHex +
|
||||
listOfNotNull(
|
||||
contentType?.let { arrayOf(MIME_TYPE, it) },
|
||||
arrayOf(ENCRYPTION_ALGORITHM, algo),
|
||||
arrayOf(ENCRYPTION_KEY, key.toHexKey()),
|
||||
nonce?.let { arrayOf(ENCRYPTION_NONCE, it.toHexKey()) },
|
||||
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf(ALT, ALT_DESCRIPTION),
|
||||
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
|
||||
hash?.let { arrayOf(HASH, it) },
|
||||
size?.let { arrayOf(FILE_SIZE, it.toString()) },
|
||||
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
|
||||
blurhash?.let { arrayOf(BLUR_HASH, it) },
|
||||
sensitiveContent?.let {
|
||||
if (it) {
|
||||
arrayOf("content-warning", "")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
).toTypedArray()
|
||||
}
|
||||
|
||||
fun create(
|
||||
url: String,
|
||||
to: List<HexKey>,
|
||||
repliesTo: List<HexKey>? = null,
|
||||
contentType: String?,
|
||||
algo: String,
|
||||
key: ByteArray,
|
||||
nonce: ByteArray? = null,
|
||||
originalHash: String? = null,
|
||||
hash: String? = null,
|
||||
size: Int? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
alt: String?,
|
||||
signer: NostrSigner,
|
||||
isDraft: Boolean,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
onReady: (ChatMessageEncryptedFileHeaderEvent) -> Unit,
|
||||
) {
|
||||
val tags = buildTags(to, repliesTo, contentType, algo, key, nonce, originalHash, hash, size, dimensions, blurhash, sensitiveContent, alt)
|
||||
if (isDraft) {
|
||||
signer.assembleRumor(createdAt, KIND, tags, url, onReady)
|
||||
} else {
|
||||
signer.sign(createdAt, KIND, tags, url, onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ class ChatMessageEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
ChatroomKeyable {
|
||||
ChatroomKeyable,
|
||||
NIP17Group {
|
||||
/** Recipients intended to receive this conversation */
|
||||
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
|
||||
|
||||
@@ -62,6 +63,8 @@ class ChatMessageEvent(
|
||||
return result
|
||||
}
|
||||
|
||||
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
|
||||
|
||||
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
|
||||
|
||||
companion object {
|
||||
@@ -111,6 +114,10 @@ class ChatMessageEvent(
|
||||
}
|
||||
}
|
||||
|
||||
interface NIP17Group {
|
||||
fun groupMembers(): Set<HexKey>
|
||||
}
|
||||
|
||||
interface ChatroomKeyable {
|
||||
fun chatroomKey(toRemove: HexKey): ChatroomKey
|
||||
}
|
||||
|
||||
@@ -59,6 +59,20 @@ class EventFactory {
|
||||
ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND -> {
|
||||
if (id.isBlank()) {
|
||||
ChatMessageEncryptedFileHeaderEvent(
|
||||
Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey(),
|
||||
pubKey,
|
||||
createdAt,
|
||||
tags,
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
} else {
|
||||
ChatMessageEncryptedFileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
}
|
||||
ChatMessageEvent.KIND -> {
|
||||
if (id.isBlank()) {
|
||||
ChatMessageEvent(
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.events
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
@@ -120,6 +121,65 @@ class NIP17Factory {
|
||||
}
|
||||
}
|
||||
|
||||
fun createEncryptedFileNIP17(
|
||||
url: String,
|
||||
to: List<HexKey>,
|
||||
repliesToHex: List<HexKey>? = null,
|
||||
contentType: String?,
|
||||
algo: String,
|
||||
key: ByteArray,
|
||||
nonce: ByteArray? = null,
|
||||
originalHash: String? = null,
|
||||
hash: String? = null,
|
||||
size: Int? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
alt: String?,
|
||||
draftTag: String? = null,
|
||||
signer: NostrSigner,
|
||||
onReady: (Result) -> Unit,
|
||||
) {
|
||||
val senderPublicKey = signer.pubKey
|
||||
|
||||
ChatMessageEncryptedFileHeaderEvent.create(
|
||||
url = url,
|
||||
to = to,
|
||||
repliesTo = repliesToHex,
|
||||
contentType = contentType,
|
||||
algo = algo,
|
||||
key = key,
|
||||
nonce = nonce,
|
||||
originalHash = originalHash,
|
||||
hash = hash,
|
||||
size = size,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
alt = alt,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) { senderMessage ->
|
||||
if (draftTag != null) {
|
||||
onReady(
|
||||
Result(
|
||||
msg = senderMessage,
|
||||
wraps = listOf(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps ->
|
||||
onReady(
|
||||
Result(
|
||||
msg = senderMessage,
|
||||
wraps = wraps,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createReactionWithinGroup(
|
||||
content: String,
|
||||
originalNote: EventInterface,
|
||||
|
||||
Reference in New Issue
Block a user