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.Account
|
||||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
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.blossom.BlossomUploader
|
||||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||||
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
|
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.DEFAULT_MEDIA_SERVERS
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
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.CryptoUtils
|
||||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
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.ChannelCreateEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||||
import com.vitorpamplona.quartz.events.CommentEvent
|
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.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||||
import com.vitorpamplona.quartz.events.NIP17Factory
|
import com.vitorpamplona.quartz.events.NIP17Factory
|
||||||
|
import com.vitorpamplona.quartz.events.NIP17Group
|
||||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
|
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
|
||||||
import com.vitorpamplona.quartz.events.OtsEvent
|
import com.vitorpamplona.quartz.events.OtsEvent
|
||||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||||
@@ -1233,14 +1233,9 @@ class Account(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.event is ChatMessageEvent) {
|
val noteEvent = note.event
|
||||||
val event = note.event as ChatMessageEvent
|
if (noteEvent is NIP17Group) {
|
||||||
val users =
|
val users = noteEvent.groupMembers().toList()
|
||||||
event
|
|
||||||
.recipientsPubKey()
|
|
||||||
.plus(event.pubKey)
|
|
||||||
.toSet()
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
if (reaction.startsWith(":")) {
|
if (reaction.startsWith(":")) {
|
||||||
val emojiUrl = EmojiUrl.decode(reaction)
|
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(
|
fun sendNIP17PrivateMessage(
|
||||||
message: String,
|
message: String,
|
||||||
toUsers: List<HexKey>,
|
toUsers: List<HexKey>,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.events.ChannelListEvent
|
|||||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
|
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
|
||||||
|
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||||
@@ -625,6 +626,8 @@ object LocalCache {
|
|||||||
is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||||
|
|
||||||
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||||
|
is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||||
|
|
||||||
is LnZapEvent ->
|
is LnZapEvent ->
|
||||||
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
|
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
|
||||||
@@ -1621,7 +1624,50 @@ object LocalCache {
|
|||||||
// Already processed this event.
|
// Already processed this event.
|
||||||
if (note.event != null) return
|
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()
|
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
|
||||||
|
|
||||||
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||||
@@ -2264,7 +2310,27 @@ object LocalCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ChatMessageEvent -> {
|
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()
|
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
|
||||||
|
|
||||||
if (recipients.isNotEmpty()) {
|
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 -> {
|
is ChannelMessageEvent -> {
|
||||||
draft.channel()?.let { channelId ->
|
draft.channel()?.let { channelId ->
|
||||||
checkGetOrCreateChannel(channelId)?.let { channel ->
|
checkGetOrCreateChannel(channelId)?.let { channel ->
|
||||||
@@ -2398,6 +2484,7 @@ object LocalCache {
|
|||||||
is ChannelMessageEvent -> consume(event, relay)
|
is ChannelMessageEvent -> consume(event, relay)
|
||||||
is ChannelMetadataEvent -> consume(event)
|
is ChannelMetadataEvent -> consume(event)
|
||||||
is ChannelMuteUserEvent -> consume(event)
|
is ChannelMuteUserEvent -> consume(event)
|
||||||
|
is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay)
|
||||||
is ChatMessageEvent -> consume(event, relay)
|
is ChatMessageEvent -> consume(event, relay)
|
||||||
is ChatMessageRelayListEvent -> consume(event, relay)
|
is ChatMessageRelayListEvent -> consume(event, relay)
|
||||||
is ClassifiedsEvent -> 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.note.showAmount
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.quartz.encoders.toNpub
|
import com.vitorpamplona.quartz.encoders.toNpub
|
||||||
|
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.events.DraftEvent
|
import com.vitorpamplona.quartz.events.DraftEvent
|
||||||
import com.vitorpamplona.quartz.events.Event
|
import com.vitorpamplona.quartz.events.Event
|
||||||
@@ -121,6 +122,9 @@ class EventNotificationConsumer(
|
|||||||
} else if (innerEvent is ChatMessageEvent) {
|
} else if (innerEvent is ChatMessageEvent) {
|
||||||
Log.d(TAG, "New ChatMessage to Notify")
|
Log.d(TAG, "New ChatMessage to Notify")
|
||||||
notify(innerEvent, signer, account)
|
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(
|
private fun notify(
|
||||||
event: ChatMessageEvent,
|
event: ChatMessageEvent,
|
||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
|
|||||||
+18
-2
@@ -18,6 +18,22 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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 android.util.Log
|
||||||
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.OkHttpClient
|
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.InetSocketAddress
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
object HttpClientManager {
|
object HttpClientManager {
|
||||||
val rootClient =
|
private val rootClient =
|
||||||
OkHttpClient
|
OkHttpClient
|
||||||
.Builder()
|
.Builder()
|
||||||
.followRedirects(true)
|
.followRedirects(true)
|
||||||
@@ -50,6 +45,8 @@ object HttpClientManager {
|
|||||||
|
|
||||||
private var currentProxy: Proxy? = null
|
private var currentProxy: Proxy? = null
|
||||||
|
|
||||||
|
private val cache = EncryptionKeyCache()
|
||||||
|
|
||||||
fun setDefaultProxy(proxy: Proxy?) {
|
fun setDefaultProxy(proxy: Proxy?) {
|
||||||
if (currentProxy != proxy) {
|
if (currentProxy != proxy) {
|
||||||
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
|
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
|
||||||
@@ -96,67 +93,10 @@ object HttpClientManager {
|
|||||||
.writeTimeout(duration)
|
.writeTimeout(duration)
|
||||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||||
.addNetworkInterceptor(LoggingInterceptor())
|
.addNetworkInterceptor(LoggingInterceptor())
|
||||||
.addNetworkInterceptor(EncryptedBlobInterceptor())
|
.addNetworkInterceptor(EncryptedBlobInterceptor(cache))
|
||||||
.build()
|
.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? =
|
fun getCurrentProxyPort(useProxy: Boolean): Int? =
|
||||||
if (useProxy) {
|
if (useProxy) {
|
||||||
(currentProxy?.address() as? InetSocketAddress)?.port
|
(currentProxy?.address() as? InetSocketAddress)?.port
|
||||||
@@ -180,4 +120,9 @@ object HttpClientManager {
|
|||||||
fun setDefaultProxyOnPort(port: Int) {
|
fun setDefaultProxyOnPort(port: Int) {
|
||||||
setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
|
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 =
|
val player =
|
||||||
ExoPlayer.Builder(context).run {
|
ExoPlayer.Builder(context).run {
|
||||||
dataSourceFactory?.let { setMediaSourceFactory(it) }
|
setMediaSourceFactory(dataSourceFactory)
|
||||||
build()
|
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 android.util.Log
|
||||||
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
|
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
|
||||||
import com.vitorpamplona.amethyst.service.Blurhash
|
import com.vitorpamplona.amethyst.service.Blurhash
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ImageDownloader
|
|
||||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||||
import com.vitorpamplona.quartz.encoders.Dimension
|
import com.vitorpamplona.quartz.encoders.Dimension
|
||||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
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
|
* 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.
|
* 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 com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.content.Context
|
||||||
import android.graphics.Bitmap
|
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
|
* 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.
|
* 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.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
|
||||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
|
||||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState.UploadingFinalState
|
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
|
||||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressorResult
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
@@ -64,8 +60,6 @@ sealed class UploadingState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class UploadOrchestrator {
|
class UploadOrchestrator {
|
||||||
private val compressor = MediaCompressor()
|
|
||||||
|
|
||||||
val progress = MutableStateFlow(0.0)
|
val progress = MutableStateFlow(0.0)
|
||||||
val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready)
|
val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready)
|
||||||
|
|
||||||
@@ -79,7 +73,10 @@ class UploadOrchestrator {
|
|||||||
vararg params: String,
|
vararg params: String,
|
||||||
) = UploadingState.Error(resId, params).also { updateState(0.0, it) }
|
) = 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(
|
fun updateState(
|
||||||
newProgress: Double,
|
newProgress: Double,
|
||||||
@@ -92,6 +89,8 @@ class UploadOrchestrator {
|
|||||||
private fun uploadNIP95(
|
private fun uploadNIP95(
|
||||||
fileUri: Uri,
|
fileUri: Uri,
|
||||||
contentType: String?,
|
contentType: String?,
|
||||||
|
originalContentType: String?,
|
||||||
|
originalHash: String?,
|
||||||
context: Context,
|
context: Context,
|
||||||
): UploadingFinalState {
|
): UploadingFinalState {
|
||||||
updateState(0.4, UploadingState.Uploading)
|
updateState(0.4, UploadingState.Uploading)
|
||||||
@@ -117,7 +116,7 @@ class UploadOrchestrator {
|
|||||||
|
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
return finish(OrchestratorResult.NIP95Result(it, bytes))
|
return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
|
||||||
},
|
},
|
||||||
onFailure = {
|
onFailure = {
|
||||||
return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
|
return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
|
||||||
@@ -135,6 +134,8 @@ class UploadOrchestrator {
|
|||||||
alt: String?,
|
alt: String?,
|
||||||
sensitiveContent: Boolean,
|
sensitiveContent: Boolean,
|
||||||
serverBaseUrl: String,
|
serverBaseUrl: String,
|
||||||
|
contentTypeForResult: String?,
|
||||||
|
originalHash: String?,
|
||||||
account: Account,
|
account: Account,
|
||||||
context: Context,
|
context: Context,
|
||||||
): UploadingFinalState {
|
): UploadingFinalState {
|
||||||
@@ -159,6 +160,8 @@ class UploadOrchestrator {
|
|||||||
verifyHeader(
|
verifyHeader(
|
||||||
uploadResult = result,
|
uploadResult = result,
|
||||||
localContentType = contentType,
|
localContentType = contentType,
|
||||||
|
originalContentType = contentTypeForResult,
|
||||||
|
originalHash = originalHash,
|
||||||
forceProxy = account::shouldUseTorForNIP96,
|
forceProxy = account::shouldUseTorForNIP96,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -174,6 +177,8 @@ class UploadOrchestrator {
|
|||||||
alt: String?,
|
alt: String?,
|
||||||
sensitiveContent: Boolean,
|
sensitiveContent: Boolean,
|
||||||
serverBaseUrl: String,
|
serverBaseUrl: String,
|
||||||
|
contentTypeForResult: String?,
|
||||||
|
originalHash: String?,
|
||||||
account: Account,
|
account: Account,
|
||||||
context: Context,
|
context: Context,
|
||||||
): UploadingFinalState {
|
): UploadingFinalState {
|
||||||
@@ -197,6 +202,8 @@ class UploadOrchestrator {
|
|||||||
uploadResult = result,
|
uploadResult = result,
|
||||||
localContentType = contentType,
|
localContentType = contentType,
|
||||||
forceProxy = account::shouldUseTorForNIP96,
|
forceProxy = account::shouldUseTorForNIP96,
|
||||||
|
originalHash = originalHash,
|
||||||
|
originalContentType = contentTypeForResult,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
@@ -207,6 +214,8 @@ class UploadOrchestrator {
|
|||||||
private suspend fun verifyHeader(
|
private suspend fun verifyHeader(
|
||||||
uploadResult: MediaUploadResult,
|
uploadResult: MediaUploadResult,
|
||||||
localContentType: String?,
|
localContentType: String?,
|
||||||
|
originalContentType: String?,
|
||||||
|
originalHash: String?,
|
||||||
forceProxy: (String) -> Boolean,
|
forceProxy: (String) -> Boolean,
|
||||||
): UploadingFinalState {
|
): UploadingFinalState {
|
||||||
if (uploadResult.url.isNullOrBlank()) {
|
if (uploadResult.url.isNullOrBlank()) {
|
||||||
@@ -229,7 +238,16 @@ class UploadOrchestrator {
|
|||||||
|
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = {
|
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 = {
|
onFailure = {
|
||||||
return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName)
|
return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName)
|
||||||
@@ -244,16 +262,32 @@ class UploadOrchestrator {
|
|||||||
class NIP95Result(
|
class NIP95Result(
|
||||||
val fileHeader: FileHeader,
|
val fileHeader: FileHeader,
|
||||||
val bytes: ByteArray,
|
val bytes: ByteArray,
|
||||||
|
val mimeTypeBeforeEncryption: String?,
|
||||||
|
val hashBeforeEncryption: String?,
|
||||||
) : OrchestratorResult()
|
) : OrchestratorResult()
|
||||||
|
|
||||||
class ServerResult(
|
class ServerResult(
|
||||||
val fileHeader: FileHeader,
|
val fileHeader: FileHeader,
|
||||||
val url: String,
|
val url: String,
|
||||||
val magnet: String?,
|
val magnet: String?,
|
||||||
val originalHash: String?,
|
val uploadedHash: String?,
|
||||||
|
val mimeTypeBeforeEncryption: String?,
|
||||||
|
val hashBeforeEncryption: String?,
|
||||||
) : OrchestratorResult()
|
) : 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(
|
suspend fun upload(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String?,
|
mimeType: String?,
|
||||||
@@ -264,18 +298,33 @@ class UploadOrchestrator {
|
|||||||
account: Account,
|
account: Account,
|
||||||
context: Context,
|
context: Context,
|
||||||
): UploadingFinalState {
|
): UploadingFinalState {
|
||||||
val result =
|
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context)
|
||||||
if (compressionQuality != CompressorQuality.UNCOMPRESSED) {
|
|
||||||
updateState(0.02, UploadingState.Compressing)
|
|
||||||
compressor.compress(uri, mimeType, compressionQuality, context.applicationContext)
|
|
||||||
} else {
|
|
||||||
MediaCompressorResult(uri, mimeType, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (server.type) {
|
return when (server.type) {
|
||||||
ServerType.NIP95 -> uploadNIP95(result.uri, result.contentType, context)
|
ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context)
|
||||||
ServerType.NIP96 -> uploadNIP96(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, account, context)
|
ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, sensitiveContent, server.baseUrl, null, null, account, context)
|
||||||
ServerType.Blossom -> uploadBlossom(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, 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)
|
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||||
if (errorMessage != null) {
|
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) {
|
} 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 {
|
} 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(
|
suspend fun delete(
|
||||||
hash: String,
|
hash: String,
|
||||||
contentType: String?,
|
contentType: String?,
|
||||||
|
|||||||
+9
-7
@@ -185,7 +185,7 @@ class Nip96Uploader {
|
|||||||
} else if (result.status == "success" && result.nip94Event != null) {
|
} else if (result.status == "success" && result.nip94Event != null) {
|
||||||
return convertToMediaResult(result.nip94Event)
|
return convertToMediaResult(result.nip94Event)
|
||||||
} else {
|
} else {
|
||||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
|
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -207,16 +207,18 @@ class Nip96Uploader {
|
|||||||
|
|
||||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||||
if (errorMessage != null) {
|
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) {
|
} 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 {
|
} 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 {
|
fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult {
|
||||||
// Images don't seem to be ready immediately after upload
|
// Images don't seem to be ready immediately after upload
|
||||||
val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
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" }
|
?.firstOrNull { it.size > 1 && it[0] == "m" }
|
||||||
?.get(1)
|
?.get(1)
|
||||||
?.ifBlank { null }
|
?.ifBlank { null }
|
||||||
val originalHash =
|
val hash =
|
||||||
nip96.tags
|
nip96.tags
|
||||||
?.firstOrNull { it.size > 1 && it[0] == "ox" }
|
?.firstOrNull { it.size > 1 && it[0] == "x" }
|
||||||
?.get(1)
|
?.get(1)
|
||||||
?.ifBlank { null }
|
?.ifBlank { null }
|
||||||
val dim =
|
val dim =
|
||||||
@@ -245,7 +247,7 @@ class Nip96Uploader {
|
|||||||
return MediaUploadResult(
|
return MediaUploadResult(
|
||||||
url = imageUrl,
|
url = imageUrl,
|
||||||
type = remoteMimeType,
|
type = remoteMimeType,
|
||||||
sha256 = originalHash,
|
sha256 = hash,
|
||||||
dimension = dim,
|
dimension = dim,
|
||||||
magnet = magnet,
|
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.placeholderText
|
||||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -328,13 +327,13 @@ fun EditPostView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (postViewModel.mediaToUpload.isNotEmpty()) {
|
postViewModel.multiOrchestrator?.let {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||||
) {
|
) {
|
||||||
ImageVideoDescription(
|
ImageVideoDescription(
|
||||||
postViewModel.mediaToUpload,
|
it,
|
||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||||
@@ -343,7 +342,7 @@ fun EditPostView(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete = postViewModel::deleteMediaToUpload,
|
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() } },
|
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.TextRange
|
|||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
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.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
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.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
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.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||||
import com.vitorpamplona.quartz.encoders.IMetaTagBuilder
|
import com.vitorpamplona.quartz.encoders.IMetaTagBuilder
|
||||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.joinAll
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
@@ -77,7 +76,7 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||||
|
|
||||||
// Images and Videos
|
// Images and Videos
|
||||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||||
|
|
||||||
// Invoices
|
// Invoices
|
||||||
var canAddInvoice by mutableStateOf(false)
|
var canAddInvoice by mutableStateOf(false)
|
||||||
@@ -102,7 +101,7 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
this.account = accountViewModel.account
|
this.account = accountViewModel.account
|
||||||
|
|
||||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||||
mediaToUpload = persistentListOf()
|
multiOrchestrator = null
|
||||||
|
|
||||||
message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
|
message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
|
||||||
urlPreview = findUrlInMessage()
|
urlPreview = findUrlInMessage()
|
||||||
@@ -156,38 +155,27 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
onError: (String, String) -> Unit,
|
onError: (String, String) -> Unit,
|
||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
val myAccount = account ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val myAccount = account ?: return@launch
|
||||||
|
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||||
|
|
||||||
isUploadingImage = true
|
isUploadingImage = true
|
||||||
|
|
||||||
val jobs =
|
val results =
|
||||||
mediaToUpload.map { myGalleryUri ->
|
myMultiOrchestrator.upload(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope,
|
||||||
myGalleryUri.orchestrator.upload(
|
alt,
|
||||||
myGalleryUri.media.uri,
|
sensitiveContent,
|
||||||
myGalleryUri.media.mimeType,
|
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||||
alt,
|
server,
|
||||||
sensitiveContent,
|
myAccount,
|
||||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
context,
|
||||||
server,
|
)
|
||||||
myAccount,
|
|
||||||
context,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.joinAll()
|
if (results.allGood) {
|
||||||
|
results.successful.forEach { state ->
|
||||||
val allGood =
|
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||||
mediaToUpload.mapNotNull {
|
account?.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, sensitiveContent) { nip95 ->
|
||||||
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 ->
|
|
||||||
nip95attachments = nip95attachments + nip95
|
nip95attachments = nip95attachments + nip95
|
||||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||||
|
|
||||||
@@ -197,33 +185,36 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
|
|
||||||
urlPreview = findUrlInMessage()
|
urlPreview = findUrlInMessage()
|
||||||
}
|
}
|
||||||
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||||
val iMeta =
|
val iMeta =
|
||||||
IMetaTagBuilder(it.result.url)
|
IMetaTagBuilder(state.result.url)
|
||||||
.apply {
|
.apply {
|
||||||
hash(it.result.fileHeader.hash)
|
hash(state.result.fileHeader.hash)
|
||||||
size(it.result.fileHeader.size)
|
size(state.result.fileHeader.size)
|
||||||
it.result.fileHeader.mimeType
|
state.result.fileHeader.mimeType
|
||||||
?.let { mimeType(it) }
|
?.let { mimeType(it) }
|
||||||
it.result.fileHeader.dim
|
state.result.fileHeader.dim
|
||||||
?.let { dims(it) }
|
?.let { dims(it) }
|
||||||
it.result.fileHeader.blurHash
|
state.result.fileHeader.blurHash
|
||||||
?.let { blurhash(it.blurhash) }
|
?.let { blurhash(it.blurhash) }
|
||||||
it.result.magnet?.let { magnet(it) }
|
state.result.magnet?.let { magnet(it) }
|
||||||
it.result.originalHash?.let { originalHash(it) }
|
state.result.uploadedHash?.let { originalHash(it) }
|
||||||
alt?.let { alt(it) }
|
alt?.let { alt(it) }
|
||||||
// TODO: Support Reasons on images
|
|
||||||
if (sensitiveContent) sensitiveContent("")
|
if (sensitiveContent) sensitiveContent("")
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||||
|
|
||||||
message = message.insertUrlAtCursor(it.result.url)
|
message = message.insertUrlAtCursor(state.result.url)
|
||||||
urlPreview = findUrlInMessage()
|
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
|
isUploadingImage = false
|
||||||
@@ -236,7 +227,7 @@ open class EditPostViewModel : ViewModel() {
|
|||||||
|
|
||||||
editedFromNote = null
|
editedFromNote = null
|
||||||
|
|
||||||
mediaToUpload = persistentListOf()
|
multiOrchestrator = null
|
||||||
urlPreview = null
|
urlPreview = null
|
||||||
isUploadingImage = false
|
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>) {
|
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||||
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
multiOrchestrator = MultiOrchestrator(uris)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
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.compose.runtime.setValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
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.DEFAULT_MEDIA_SERVERS
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState
|
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
|
||||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.joinAll
|
import kotlinx.coroutines.joinAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -59,7 +59,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
var sensitiveContent by mutableStateOf(false)
|
var sensitiveContent by mutableStateOf(false)
|
||||||
|
|
||||||
// Images and Videos
|
// Images and Videos
|
||||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||||
var onceUploaded: () -> Unit = {}
|
var onceUploaded: () -> Unit = {}
|
||||||
|
|
||||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||||
@@ -71,7 +71,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
) {
|
) {
|
||||||
this.caption = ""
|
this.caption = ""
|
||||||
this.account = account
|
this.account = account
|
||||||
this.mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||||
this.selectedServer = defaultServer()
|
this.selectedServer = defaultServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,46 +83,37 @@ open class NewMediaModel : ViewModel() {
|
|||||||
fun upload(
|
fun upload(
|
||||||
context: Context,
|
context: Context,
|
||||||
relayList: List<RelaySetupInfo>,
|
relayList: List<RelaySetupInfo>,
|
||||||
|
onError: (String, String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val myAccount = account ?: return
|
|
||||||
if (relayList.isEmpty()) return
|
|
||||||
val serverToUse = selectedServer ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val myAccount = account ?: return@launch
|
||||||
|
if (relayList.isEmpty()) return@launch
|
||||||
|
val serverToUse = selectedServer ?: return@launch
|
||||||
|
|
||||||
|
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||||
|
|
||||||
isUploadingImage = true
|
isUploadingImage = true
|
||||||
|
|
||||||
val jobs =
|
val results =
|
||||||
mediaToUpload.map { myGalleryUri ->
|
myMultiOrchestrator.upload(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope,
|
||||||
myGalleryUri.orchestrator.upload(
|
caption,
|
||||||
myGalleryUri.media.uri,
|
sensitiveContent,
|
||||||
myGalleryUri.media.mimeType,
|
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
|
||||||
caption,
|
serverToUse,
|
||||||
sensitiveContent,
|
myAccount,
|
||||||
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
|
context,
|
||||||
serverToUse,
|
)
|
||||||
myAccount,
|
|
||||||
context,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.joinAll()
|
if (results.allGood) {
|
||||||
|
|
||||||
val allGood =
|
|
||||||
mediaToUpload.mapNotNull {
|
|
||||||
it.orchestrator.progressState.value as? UploadingState.Finished
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allGood.size == mediaToUpload.size) {
|
|
||||||
// It all finished successfully
|
// It all finished successfully
|
||||||
val nip95s =
|
val nip95s =
|
||||||
allGood.mapNotNull {
|
results.successful.mapNotNull {
|
||||||
it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result
|
it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result
|
||||||
}
|
}
|
||||||
|
|
||||||
val videosAndOthers =
|
val videosAndOthers =
|
||||||
allGood.mapNotNull {
|
results.successful.mapNotNull {
|
||||||
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||||
if (map != null && !isImage(map.url, map.fileHeader.mimeType)) {
|
if (map != null && !isImage(map.url, map.fileHeader.mimeType)) {
|
||||||
map
|
map
|
||||||
@@ -132,7 +123,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val imageUrls =
|
val imageUrls =
|
||||||
allGood
|
results.successful
|
||||||
.mapNotNull {
|
.mapNotNull {
|
||||||
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||||
if (map != null && isImage(map.url, map.fileHeader.mimeType)) {
|
if (map != null && isImage(map.url, map.fileHeader.mimeType)) {
|
||||||
@@ -169,7 +160,7 @@ open class NewMediaModel : ViewModel() {
|
|||||||
it.fileHeader,
|
it.fileHeader,
|
||||||
caption,
|
caption,
|
||||||
sensitiveContent,
|
sensitiveContent,
|
||||||
it.originalHash,
|
it.uploadedHash,
|
||||||
relayList,
|
relayList,
|
||||||
) {
|
) {
|
||||||
continuation.resume(true)
|
continuation.resume(true)
|
||||||
@@ -203,22 +194,26 @@ open class NewMediaModel : ViewModel() {
|
|||||||
|
|
||||||
onceUploaded()
|
onceUploaded()
|
||||||
cancelModel()
|
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() {
|
open fun cancelModel() {
|
||||||
mediaToUpload = persistentListOf()
|
multiOrchestrator = null
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
caption = ""
|
caption = ""
|
||||||
selectedServer = defaultServer()
|
selectedServer = defaultServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
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]
|
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||||
|
|
||||||
|
|||||||
@@ -141,8 +141,7 @@ fun NewMediaView(
|
|||||||
PostButton(
|
PostButton(
|
||||||
onPost = {
|
onPost = {
|
||||||
onClose()
|
onClose()
|
||||||
postViewModel.upload(context, relayList)
|
postViewModel.upload(context, relayList, onError = accountViewModel::toast)
|
||||||
// accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
|
||||||
postViewModel.selectedServer?.let {
|
postViewModel.selectedServer?.let {
|
||||||
if (it.type != ServerType.NIP95) {
|
if (it.type != ServerType.NIP95) {
|
||||||
account.settings.changeDefaultFileServer(it)
|
account.settings.changeDefaultFileServer(it)
|
||||||
@@ -218,11 +217,13 @@ fun ImageVideoPost(
|
|||||||
}.toImmutableList()
|
}.toImmutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
ShowImageUploadGallery(
|
postViewModel.multiOrchestrator?.let {
|
||||||
postViewModel.mediaToUpload,
|
ShowImageUploadGallery(
|
||||||
postViewModel::deleteMediaToUpload,
|
it,
|
||||||
accountViewModel,
|
postViewModel::deleteMediaToUpload,
|
||||||
)
|
accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
label = { Text(text = stringRes(R.string.add_caption)) },
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
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.model.User
|
||||||
import com.vitorpamplona.amethyst.service.LocationState
|
import com.vitorpamplona.amethyst.service.LocationState
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
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.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
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.components.Split
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||||
|
import com.vitorpamplona.quartz.crypto.nip17.AESGCM
|
||||||
import com.vitorpamplona.quartz.encoders.Hex
|
import com.vitorpamplona.quartz.encoders.Hex
|
||||||
import com.vitorpamplona.quartz.encoders.HexKey
|
import com.vitorpamplona.quartz.encoders.HexKey
|
||||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
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.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
|
||||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||||
import com.vitorpamplona.quartz.events.CommentEvent
|
import com.vitorpamplona.quartz.events.CommentEvent
|
||||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||||
@@ -68,6 +70,7 @@ import com.vitorpamplona.quartz.events.Event
|
|||||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||||
import com.vitorpamplona.quartz.events.GitIssueEvent
|
import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||||
|
import com.vitorpamplona.quartz.events.NIP17Group
|
||||||
import com.vitorpamplona.quartz.events.Price
|
import com.vitorpamplona.quartz.events.Price
|
||||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||||
import com.vitorpamplona.quartz.events.RootScope
|
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.ZapSplitSetup
|
||||||
import com.vitorpamplona.quartz.events.findURLs
|
import com.vitorpamplona.quartz.events.findURLs
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.joinAll
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -127,7 +127,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
var subject by mutableStateOf(TextFieldValue(""))
|
var subject by mutableStateOf(TextFieldValue(""))
|
||||||
|
|
||||||
// Images and Videos
|
// Images and Videos
|
||||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||||
|
|
||||||
// Polls
|
// Polls
|
||||||
var canUsePoll by mutableStateOf(false)
|
var canUsePoll by mutableStateOf(false)
|
||||||
@@ -247,7 +247,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||||
mediaToUpload = persistentListOf()
|
multiOrchestrator = null
|
||||||
|
|
||||||
quote?.let {
|
quote?.let {
|
||||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||||
@@ -331,7 +331,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
|
|
||||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||||
canAddZapRaiser = 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" }
|
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
|
||||||
forwardZapTo = Split()
|
forwardZapTo = Split()
|
||||||
@@ -364,7 +364,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
note
|
note
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) {
|
if (draftEvent !is PrivateDmEvent && draftEvent !is NIP17Group) {
|
||||||
pTags =
|
pTags =
|
||||||
draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map {
|
draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map {
|
||||||
LocalCache.getOrCreateUser(it[1])
|
LocalCache.getOrCreateUser(it[1])
|
||||||
@@ -459,7 +459,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
|
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
|
||||||
|
|
||||||
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent
|
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is NIP17Group
|
||||||
|
|
||||||
draftEvent.subject()?.let {
|
draftEvent.subject()?.let {
|
||||||
subject = TextFieldValue()
|
subject = TextFieldValue()
|
||||||
@@ -474,13 +474,13 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
TextFieldValue(draftEvent.content())
|
TextFieldValue(draftEvent.content())
|
||||||
}
|
}
|
||||||
|
|
||||||
requiresNIP17 = draftEvent is ChatMessageEvent
|
requiresNIP17 = draftEvent is NIP17Group
|
||||||
nip17 = draftEvent is ChatMessageEvent
|
nip17 = draftEvent is NIP17Group
|
||||||
|
|
||||||
if (draftEvent is ChatMessageEvent) {
|
if (draftEvent is NIP17Group) {
|
||||||
toUsers =
|
toUsers =
|
||||||
TextFieldValue(
|
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,
|
imetas = usedAttachments,
|
||||||
draftTag = localDraft,
|
draftTag = localDraft,
|
||||||
)
|
)
|
||||||
} else if (originalNote?.event is ChatMessageEvent) {
|
} else if (originalNote?.event is NIP17Group) {
|
||||||
val receivers =
|
|
||||||
(originalNote?.event as ChatMessageEvent)
|
|
||||||
.recipientsPubKey()
|
|
||||||
.plus(originalNote?.author?.pubkeyHex)
|
|
||||||
.filterNotNull()
|
|
||||||
.toSet()
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
account?.sendNIP17PrivateMessage(
|
account?.sendNIP17PrivateMessage(
|
||||||
message = tagger.message,
|
message = tagger.message,
|
||||||
toUsers = receivers,
|
toUsers = (originalNote?.event as NIP17Group).groupMembers().toList(),
|
||||||
subject = subject.text.ifBlank { null },
|
subject = subject.text.ifBlank { null },
|
||||||
replyingTo = originalNote!!,
|
replyingTo = originalNote!!,
|
||||||
mentions = tagger.pTags,
|
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(
|
fun upload(
|
||||||
alt: String?,
|
alt: String?,
|
||||||
sensitiveContent: Boolean,
|
sensitiveContent: Boolean,
|
||||||
@@ -874,36 +930,26 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
onError: (title: String, message: String) -> Unit,
|
onError: (title: String, message: String) -> Unit,
|
||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
val myAccount = account ?: return
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
val myAccount = account ?: return@launch
|
||||||
|
|
||||||
|
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
isUploadingImage = true
|
isUploadingImage = true
|
||||||
|
|
||||||
val jobs =
|
val results =
|
||||||
mediaToUpload.map { myGalleryUri ->
|
myMultiOrchestrator.upload(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope,
|
||||||
myGalleryUri.orchestrator.upload(
|
alt,
|
||||||
myGalleryUri.media.uri,
|
sensitiveContent,
|
||||||
myGalleryUri.media.mimeType,
|
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||||
alt,
|
server,
|
||||||
sensitiveContent,
|
myAccount,
|
||||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
context,
|
||||||
server,
|
)
|
||||||
myAccount,
|
|
||||||
context,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.joinAll()
|
if (results.allGood) {
|
||||||
|
results.successful.forEach {
|
||||||
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) {
|
if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||||
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 ->
|
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 ->
|
||||||
nip95attachments = nip95attachments + nip95
|
nip95attachments = nip95attachments + nip95
|
||||||
@@ -928,7 +974,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
it.result.fileHeader.blurHash
|
it.result.fileHeader.blurHash
|
||||||
?.let { blurhash(it.blurhash) }
|
?.let { blurhash(it.blurhash) }
|
||||||
it.result.magnet?.let { magnet(it) }
|
it.result.magnet?.let { magnet(it) }
|
||||||
it.result.originalHash?.let { originalHash(it) }
|
it.result.uploadedHash?.let { originalHash(it) }
|
||||||
alt?.let { alt(it) }
|
alt?.let { alt(it) }
|
||||||
// TODO: Support Reasons on images
|
// TODO: Support Reasons on images
|
||||||
if (sensitiveContent) sensitiveContent("")
|
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
|
isUploadingImage = false
|
||||||
@@ -955,7 +1005,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
|
|
||||||
forkedFromNote = null
|
forkedFromNote = null
|
||||||
|
|
||||||
mediaToUpload = persistentListOf()
|
multiOrchestrator = null
|
||||||
urlPreview = null
|
urlPreview = null
|
||||||
isUploadingImage = false
|
isUploadingImage = false
|
||||||
pTags = null
|
pTags = null
|
||||||
@@ -1004,7 +1054,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||||
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
|
this.multiOrchestrator?.remove(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
|
open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
|
||||||
@@ -1170,14 +1220,14 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
!category.text.isNullOrBlank()
|
!category.text.isNullOrBlank()
|
||||||
)
|
)
|
||||||
) &&
|
) &&
|
||||||
mediaToUpload.isEmpty()
|
multiOrchestrator == null
|
||||||
|
|
||||||
fun insertAtCursor(newElement: String) {
|
fun insertAtCursor(newElement: String) {
|
||||||
message = message.insertUrlAtCursor(newElement)
|
message = message.insertUrlAtCursor(newElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||||
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
multiOrchestrator = MultiOrchestrator(uris)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun locationFlow(): StateFlow<LocationState.LocationResult> {
|
fun locationFlow(): StateFlow<LocationState.LocationResult> {
|
||||||
|
|||||||
+2
-2
@@ -28,12 +28,12 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||||
|
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
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.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.quartz.events.GitHubIdentity
|
import com.vitorpamplona.quartz.events.GitHubIdentity
|
||||||
import com.vitorpamplona.quartz.events.MastodonIdentity
|
import com.vitorpamplona.quartz.events.MastodonIdentity
|
||||||
|
|||||||
+2
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||||
|
|
||||||
class SelectedMediaProcessing(
|
class SelectedMediaProcessing(
|
||||||
val media: SelectedMedia,
|
val media: SelectedMedia,
|
||||||
val orchestrator: UploadOrchestrator = UploadOrchestrator(),
|
val orchestrator: UploadOrchestrator = UploadOrchestrator(),
|
||||||
|
|||||||
+6
-4
@@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.vitorpamplona.amethyst.R
|
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.AutoNonlazyGrid
|
||||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||||
import com.vitorpamplona.amethyst.ui.note.CloseIcon
|
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.Size20Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShowImageUploadGallery(
|
fun ShowImageUploadGallery(
|
||||||
list: ImmutableList<SelectedMediaProcessing>,
|
list: MultiOrchestrator,
|
||||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
AutoNonlazyGrid(list.size) {
|
AutoNonlazyGrid(list.size()) {
|
||||||
ShowImageUploadItem(list[it], onDelete, accountViewModel)
|
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.RenderBadgeAward
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
|
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
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.RenderClassifieds
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
|
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
|
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.ChannelCreateEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||||
@@ -671,6 +673,18 @@ private fun RenderNoteRow(
|
|||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is ChatMessageEncryptedFileHeaderEvent -> {
|
||||||
|
RenderChatMessageEncryptedFile(
|
||||||
|
baseNote,
|
||||||
|
makeItShort,
|
||||||
|
canPreview,
|
||||||
|
quotesLeft,
|
||||||
|
backgroundColor,
|
||||||
|
editState,
|
||||||
|
accountViewModel,
|
||||||
|
nav,
|
||||||
|
)
|
||||||
|
}
|
||||||
is ClassifiedsEvent -> {
|
is ClassifiedsEvent -> {
|
||||||
RenderClassifieds(
|
RenderClassifieds(
|
||||||
noteEvent,
|
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.model.User
|
||||||
import com.vitorpamplona.amethyst.service.LocationState
|
import com.vitorpamplona.amethyst.service.LocationState
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
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.NewPollOption
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
|
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||||
@@ -506,13 +507,13 @@ fun NewPostScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (postViewModel.mediaToUpload.isNotEmpty()) {
|
postViewModel.multiOrchestrator?.let {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||||
) {
|
) {
|
||||||
ImageVideoDescription(
|
ImageVideoDescription(
|
||||||
postViewModel.mediaToUpload,
|
it,
|
||||||
accountViewModel.account.settings.defaultFileServer,
|
accountViewModel.account.settings.defaultFileServer,
|
||||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||||
@@ -521,7 +522,7 @@ fun NewPostScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete = postViewModel::deleteMediaToUpload,
|
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() } },
|
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
@@ -1666,7 +1667,7 @@ fun CreateButton(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImageVideoDescription(
|
fun ImageVideoDescription(
|
||||||
uris: ImmutableList<SelectedMediaProcessing>,
|
uris: MultiOrchestrator,
|
||||||
defaultServer: ServerName,
|
defaultServer: ServerName,
|
||||||
onAdd: (String, ServerName, Boolean, Int) -> Unit,
|
onAdd: (String, ServerName, Boolean, Int) -> Unit,
|
||||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||||
@@ -1727,7 +1728,7 @@ fun ImageVideoDescription(
|
|||||||
.padding(bottom = 10.dp),
|
.padding(bottom = 10.dp),
|
||||||
) {
|
) {
|
||||||
val text =
|
val text =
|
||||||
if (uris.size == 1) {
|
if (uris.size() == 1) {
|
||||||
if (uris.first().media.isImage() == true) {
|
if (uris.first().media.isImage() == true) {
|
||||||
R.string.content_description_add_image
|
R.string.content_description_add_image
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+3
-3
@@ -110,14 +110,14 @@ import com.vitorpamplona.amethyst.model.Note
|
|||||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||||
|
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
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.LoadNote
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
|
||||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||||
@@ -395,7 +395,7 @@ private suspend fun innerSendPost(
|
|||||||
tagger.run()
|
tagger.run()
|
||||||
|
|
||||||
val urls = findURLs(tagger.message)
|
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) {
|
if (channel is PublicChatChannel) {
|
||||||
accountViewModel.account.sendChannelMessage(
|
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.WatchUserFollows
|
||||||
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||||
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
|
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.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier
|
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.amethyst.ui.theme.placeholderText
|
||||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
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.DraftEvent
|
||||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||||
|
import com.vitorpamplona.quartz.events.NIP17Group
|
||||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||||
|
|
||||||
@@ -174,7 +177,7 @@ fun NormalChatNote(
|
|||||||
false // never shows the user's pictures
|
false // never shows the user's pictures
|
||||||
} else if (noteEvent is PrivateDmEvent) {
|
} else if (noteEvent is PrivateDmEvent) {
|
||||||
false // one-on-one, never shows it.
|
false // one-on-one, never shows it.
|
||||||
} else if (noteEvent is ChatMessageEvent) {
|
} else if (noteEvent is ChatroomKeyable) {
|
||||||
// only shows in a group chat.
|
// only shows in a group chat.
|
||||||
noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1
|
noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1
|
||||||
} else {
|
} else {
|
||||||
@@ -581,6 +584,13 @@ private fun NoteRow(
|
|||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
|
is ChatMessageEncryptedFileHeaderEvent ->
|
||||||
|
RenderEncryptedFile(
|
||||||
|
note,
|
||||||
|
backgroundBubbleColor,
|
||||||
|
accountViewModel,
|
||||||
|
nav,
|
||||||
|
)
|
||||||
else ->
|
else ->
|
||||||
RenderRegularTextNote(
|
RenderRegularTextNote(
|
||||||
note,
|
note,
|
||||||
@@ -631,16 +641,9 @@ private fun RenderDraftEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ConstrainedStatusRow(
|
|
||||||
firstColumn: @Composable () -> Unit,
|
|
||||||
secondColumn: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncognitoBadge(baseNote: Note) {
|
fun IncognitoBadge(baseNote: Note) {
|
||||||
if (baseNote.event is ChatMessageEvent) {
|
if (baseNote.event is NIP17Group) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.incognito),
|
painter = painterResource(id = R.drawable.incognito),
|
||||||
null,
|
null,
|
||||||
|
|||||||
+34
-26
@@ -85,12 +85,13 @@ import com.vitorpamplona.amethyst.R
|
|||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
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.CrossfadeIfEnabled
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
|
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
|
||||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
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.StdPadding
|
||||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
|
||||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||||
|
import com.vitorpamplona.quartz.events.NIP17Group
|
||||||
import com.vitorpamplona.quartz.events.findURLs
|
import com.vitorpamplona.quartz.events.findURLs
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -482,18 +484,34 @@ fun ChatroomScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LAST ROW
|
// LAST ROW
|
||||||
PrivateMessageEditFieldRow(newPostModel, accountViewModel) {
|
PrivateMessageEditFieldRow(
|
||||||
scope.launch(Dispatchers.IO) {
|
newPostModel,
|
||||||
innerSendPost(newPostModel, room, replyTo, accountViewModel, null)
|
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
|
replyTo.value = null
|
||||||
feedViewModel.sendToTop()
|
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 urls = findURLs(newPostModel.message.text)
|
||||||
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() }
|
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(
|
accountViewModel.account.sendNIP17PrivateMessage(
|
||||||
message = newPostModel.message.text,
|
message = newPostModel.message.text,
|
||||||
toUsers = room.users.toList(),
|
toUsers = room.users.toList(),
|
||||||
@@ -535,6 +553,7 @@ fun PrivateMessageEditFieldRow(
|
|||||||
channelScreenModel: NewPostViewModel,
|
channelScreenModel: NewPostViewModel,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
onSendNewMessage: () -> Unit,
|
onSendNewMessage: () -> Unit,
|
||||||
|
onSendNewMedia: (ImmutableList<SelectedMedia>) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = EditFieldModifier,
|
modifier = EditFieldModifier,
|
||||||
@@ -579,19 +598,8 @@ fun PrivateMessageEditFieldRow(
|
|||||||
Modifier
|
Modifier
|
||||||
.size(30.dp)
|
.size(30.dp)
|
||||||
.padding(start = 2.dp),
|
.padding(start = 2.dp),
|
||||||
) {
|
onImageChosen = onSendNewMedia,
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
|
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.BadgeAwardEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
|
||||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||||
|
import com.vitorpamplona.quartz.events.NIP17Group
|
||||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||||
import com.vitorpamplona.quartz.events.RepostEvent
|
import com.vitorpamplona.quartz.events.RepostEvent
|
||||||
@@ -285,7 +285,7 @@ class CardFeedContentState(
|
|||||||
it.event !is GenericRepostEvent &&
|
it.event !is GenericRepostEvent &&
|
||||||
it.event !is LnZapEvent
|
it.event !is LnZapEvent
|
||||||
}.map {
|
}.map {
|
||||||
if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) {
|
if (it.event is PrivateDmEvent || it.event is NIP17Group) {
|
||||||
MessageSetCard(it)
|
MessageSetCard(it)
|
||||||
} else if (it.event is BadgeAwardEvent) {
|
} else if (it.event is BadgeAwardEvent) {
|
||||||
BadgeCard(it)
|
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_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_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="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_upload_with_message">Failed to upload: %1$s</string>
|
||||||
<string name="failed_to_delete_with_message">Failed to delete: %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>
|
<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.net.Uri
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
|
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 com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
|
||||||
import id.zelory.compressor.Compressor
|
import id.zelory.compressor.Compressor
|
||||||
import io.mockk.MockKAnnotations
|
import io.mockk.MockKAnnotations
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ class Relay(
|
|||||||
lastConnectTentative = TimeUtils.now()
|
lastConnectTentative = TimeUtils.now()
|
||||||
|
|
||||||
socket = socketBuilder.build(url, false, RelayListener(onConnected))
|
socket = socketBuilder.build(url, false, RelayListener(onConnected))
|
||||||
|
socket?.connect()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
package com.vitorpamplona.ammolite.sockets
|
package com.vitorpamplona.ammolite.sockets
|
||||||
|
|
||||||
interface WebSocket {
|
interface WebSocket {
|
||||||
|
fun connect()
|
||||||
|
|
||||||
fun cancel()
|
fun cancel()
|
||||||
|
|
||||||
fun send(msg: String): Boolean
|
fun send(msg: String): Boolean
|
||||||
|
|||||||
+33
-2
@@ -43,7 +43,7 @@ abstract class MediaUrlContent(
|
|||||||
) : BaseMediaContent(description, dim, blurhash)
|
) : BaseMediaContent(description, dim, blurhash)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
class MediaUrlImage(
|
open class MediaUrlImage(
|
||||||
url: String,
|
url: String,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
hash: String? = null,
|
hash: String? = null,
|
||||||
@@ -54,8 +54,22 @@ class MediaUrlImage(
|
|||||||
mimeType: String? = null,
|
mimeType: String? = null,
|
||||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
) : 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
|
@Immutable
|
||||||
class MediaUrlVideo(
|
open class MediaUrlVideo(
|
||||||
url: String,
|
url: String,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
hash: String? = null,
|
hash: String? = null,
|
||||||
@@ -68,6 +82,23 @@ class MediaUrlVideo(
|
|||||||
mimeType: String? = null,
|
mimeType: String? = null,
|
||||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
) : 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
|
@Immutable
|
||||||
abstract class MediaPreloadedContent(
|
abstract class MediaPreloadedContent(
|
||||||
val localFile: File?,
|
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,
|
content: String,
|
||||||
sig: HexKey,
|
sig: HexKey,
|
||||||
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||||
ChatroomKeyable {
|
ChatroomKeyable,
|
||||||
|
NIP17Group {
|
||||||
/** Recipients intended to receive this conversation */
|
/** Recipients intended to receive this conversation */
|
||||||
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
|
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ class ChatMessageEvent(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
|
||||||
|
|
||||||
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
|
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -111,6 +114,10 @@ class ChatMessageEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NIP17Group {
|
||||||
|
fun groupMembers(): Set<HexKey>
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatroomKeyable {
|
interface ChatroomKeyable {
|
||||||
fun chatroomKey(toRemove: HexKey): ChatroomKey
|
fun chatroomKey(toRemove: HexKey): ChatroomKey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,20 @@ class EventFactory {
|
|||||||
ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(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 -> {
|
ChatMessageEvent.KIND -> {
|
||||||
if (id.isBlank()) {
|
if (id.isBlank()) {
|
||||||
ChatMessageEvent(
|
ChatMessageEvent(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.events
|
package com.vitorpamplona.quartz.events
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.encoders.Dimension
|
||||||
import com.vitorpamplona.quartz.encoders.HexKey
|
import com.vitorpamplona.quartz.encoders.HexKey
|
||||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
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(
|
fun createReactionWithinGroup(
|
||||||
content: String,
|
content: String,
|
||||||
originalNote: EventInterface,
|
originalNote: EventInterface,
|
||||||
|
|||||||
Reference in New Issue
Block a user