- Adds support for multiple media uploads at the same time.
- Adds support to display PictureEvents with multiple images at the same time - Improves Uploading feedback for the NewPost screen - 10x better performance on Blurhash generation - Fixes cosine caching on Blurhash - Removes troublesome dependency on blurhash encoder liberary - Restructures contentScale for Images and Video dialogs - Refactors Media Uploaders to improve code reuse - Refactors iMeta usage on Quartz to move away from NIP-94
This commit is contained in:
@@ -233,9 +233,6 @@ dependencies {
|
||||
// enables network for coil
|
||||
implementation libs.coil.okhttp
|
||||
|
||||
// create blurhash
|
||||
implementation libs.trbl.blurhash
|
||||
|
||||
// Permission to upload pictures:
|
||||
implementation libs.accompanist.permissions
|
||||
|
||||
|
||||
@@ -26,14 +26,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
@@ -84,7 +84,7 @@ class ImageUploadTesting {
|
||||
val initialHash = CryptoUtils.sha256(paylod).toHexKey()
|
||||
val inputStream = paylod.inputStream()
|
||||
val result =
|
||||
BlossomUploader(account)
|
||||
BlossomUploader()
|
||||
.uploadImage(
|
||||
inputStream,
|
||||
initialHash,
|
||||
@@ -95,6 +95,7 @@ class ImageUploadTesting {
|
||||
sensitiveContent = null,
|
||||
server,
|
||||
forceProxy = { false },
|
||||
createBlossomUploadAuth = account::createBlossomUploadAuth,
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
)
|
||||
|
||||
@@ -116,7 +117,7 @@ class ImageUploadTesting {
|
||||
|
||||
private suspend fun testNip96(server: ServerName) {
|
||||
val serverInfo =
|
||||
Nip96Retriever()
|
||||
ServerInfoRetriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
false,
|
||||
@@ -125,7 +126,7 @@ class ImageUploadTesting {
|
||||
val paylod = getBitmap()
|
||||
val inputStream = paylod.inputStream()
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
Nip96Uploader()
|
||||
.uploadImage(
|
||||
inputStream,
|
||||
paylod.size.toLong(),
|
||||
@@ -135,6 +136,7 @@ class ImageUploadTesting {
|
||||
serverInfo,
|
||||
forceProxy = { false },
|
||||
onProgress = {},
|
||||
createHTTPAuthorization = account::createHTTPAuthorization,
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Launches an async coroutine for each item, runs the
|
||||
@@ -54,9 +54,12 @@ suspend fun <T> launchAndWaitAll(
|
||||
/**
|
||||
* Runs the function and waits for 10 seconds for any result.
|
||||
*/
|
||||
suspend inline fun <T> tryAndWait(crossinline asyncFunc: (CancellableContinuation<T>) -> Unit): T? =
|
||||
withTimeoutOrNull(10000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
suspend inline fun <T> tryAndWait(
|
||||
timeoutMillis: Long = 10000,
|
||||
crossinline asyncFunc: (Continuation<T>) -> Unit,
|
||||
): T? =
|
||||
withTimeoutOrNull(timeoutMillis) {
|
||||
suspendCoroutine { continuation ->
|
||||
asyncFunc(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.tryAndWait
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
@@ -51,9 +51,11 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.ETag
|
||||
import com.vitorpamplona.quartz.encoders.EventHint
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.PTag
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
@@ -73,7 +75,6 @@ import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.Contact
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
@@ -105,6 +106,7 @@ import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PictureEvent
|
||||
import com.vitorpamplona.quartz.events.PictureMeta
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
@@ -1495,36 +1497,45 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createHTTPAuthorization(
|
||||
suspend fun createHTTPAuthorization(
|
||||
url: String,
|
||||
method: String,
|
||||
body: ByteArray? = null,
|
||||
onReady: (HTTPAuthorizationEvent) -> Unit,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
): HTTPAuthorizationEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
|
||||
return tryAndWait { continuation ->
|
||||
HTTPAuthorizationEvent.create(url, method, body, signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createBlossomUploadAuth(
|
||||
suspend fun createBlossomUploadAuth(
|
||||
hash: HexKey,
|
||||
size: Long,
|
||||
alt: String,
|
||||
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
): BlossomAuthorizationEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, onReady = onReady)
|
||||
return tryAndWait { continuation ->
|
||||
BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createBlossomDeleteAuth(
|
||||
suspend fun createBlossomDeleteAuth(
|
||||
hash: HexKey,
|
||||
alt: String,
|
||||
onReady: (BlossomAuthorizationEvent) -> Unit,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
): BlossomAuthorizationEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, onReady = onReady)
|
||||
return tryAndWait { continuation ->
|
||||
BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun boost(note: Note) {
|
||||
@@ -1858,7 +1869,7 @@ class Account(
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
blurhash = headerInfo.blurHash?.blurhash,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer,
|
||||
@@ -1934,7 +1945,7 @@ class Account(
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
blurhash = headerInfo.blurHash?.blurhash,
|
||||
alt = alt,
|
||||
originalHash = originalHash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
@@ -1944,6 +1955,38 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAllAsOnePictureEvent(
|
||||
urlHeaderInfo: Map<String, FileHeader>,
|
||||
caption: String?,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
onReady: (Note) -> Unit,
|
||||
) {
|
||||
val iMetas =
|
||||
urlHeaderInfo.map {
|
||||
PictureMeta(
|
||||
it.key,
|
||||
it.value.mimeType,
|
||||
it.value.blurHash?.blurhash,
|
||||
it.value.dim,
|
||||
caption,
|
||||
it.value.hash,
|
||||
it.value.size.toLong(),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
PictureEvent.create(
|
||||
images = iMetas,
|
||||
msg = caption,
|
||||
markAsSensitive = sensitiveContent,
|
||||
signer = signer,
|
||||
) { event ->
|
||||
sendHeader(event, relayList = relayList, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendHeader(
|
||||
url: String,
|
||||
magnetUri: String?,
|
||||
@@ -1967,7 +2010,8 @@ class Account(
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toLong(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
blurhash = headerInfo.blurHash?.blurhash,
|
||||
markAsSensitive = sensitiveContent,
|
||||
alt = alt,
|
||||
signer = signer,
|
||||
) { event ->
|
||||
@@ -1981,7 +2025,7 @@ class Account(
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size,
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
blurhash = headerInfo.blurHash?.blurhash,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer,
|
||||
@@ -1995,7 +2039,7 @@ class Account(
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size,
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
blurhash = headerInfo.blurHash?.blurhash,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer,
|
||||
@@ -2011,7 +2055,7 @@ class Account(
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
blurhash = headerInfo.blurHash?.blurhash,
|
||||
alt = alt,
|
||||
originalHash = originalHash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
@@ -2037,7 +2081,7 @@ class Account(
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2064,7 +2108,7 @@ class Account(
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
@@ -2104,7 +2148,7 @@ class Account(
|
||||
forkedFrom: Event?,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2126,7 +2170,7 @@ class Account(
|
||||
root = root,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
forkedFrom = forkedFrom,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
@@ -2172,7 +2216,7 @@ class Account(
|
||||
forkedFrom: Event?,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2192,7 +2236,7 @@ class Account(
|
||||
torrent = root,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
forkedFrom = forkedFrom,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
@@ -2255,7 +2299,7 @@ class Account(
|
||||
replyingTo: Note,
|
||||
directMentionsUsers: Set<User> = emptySet(),
|
||||
directMentionsNotes: Set<Note> = emptySet(),
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
geohash: String? = null,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean = false,
|
||||
@@ -2298,7 +2342,7 @@ class Account(
|
||||
usersMentioned = usersMentioned,
|
||||
addressesMentioned = addressesMentioned,
|
||||
eventsMentioned = eventsMentioned,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
geohash = geohash,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
@@ -2330,7 +2374,7 @@ class Account(
|
||||
usersMentioned = usersMentioned,
|
||||
addressesMentioned = addressesMentioned,
|
||||
eventsMentioned = eventsMentioned,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
geohash = geohash,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
@@ -2364,7 +2408,7 @@ class Account(
|
||||
replyingTo: Note? = null,
|
||||
directMentionsUsers: Set<User> = emptySet(),
|
||||
directMentionsNotes: Set<Note> = emptySet(),
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
@@ -2406,7 +2450,7 @@ class Account(
|
||||
usersMentioned = usersMentioned,
|
||||
addressesMentioned = addressesMentioned,
|
||||
eventsMentioned = eventsMentioned,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
@@ -2437,7 +2481,7 @@ class Account(
|
||||
usersMentioned = usersMentioned,
|
||||
addressesMentioned = addressesMentioned,
|
||||
eventsMentioned = eventsMentioned,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
@@ -2520,7 +2564,7 @@ class Account(
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
) {
|
||||
@@ -2536,7 +2580,7 @@ class Account(
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
@@ -2563,7 +2607,7 @@ class Account(
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
) {
|
||||
@@ -2577,7 +2621,7 @@ class Account(
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
@@ -2610,7 +2654,7 @@ class Account(
|
||||
forkedFrom: Event?,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2632,7 +2676,7 @@ class Account(
|
||||
root = root,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
forkedFrom = forkedFrom,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
@@ -2702,7 +2746,7 @@ class Account(
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2726,7 +2770,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
if (draftTag != null) {
|
||||
@@ -2761,7 +2805,7 @@ class Account(
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2778,7 +2822,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
@@ -2806,7 +2850,7 @@ class Account(
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2824,7 +2868,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) {
|
||||
@@ -2852,7 +2896,7 @@ class Account(
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
sendPrivateMessage(
|
||||
@@ -2864,7 +2908,7 @@ class Account(
|
||||
wantsToMarkAsSensitive,
|
||||
zapRaiserAmount,
|
||||
geohash,
|
||||
nip94attachments,
|
||||
imetas,
|
||||
draftTag,
|
||||
)
|
||||
}
|
||||
@@ -2878,7 +2922,7 @@ class Account(
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String?,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2896,7 +2940,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
signer = signer,
|
||||
advertiseNip18 = false,
|
||||
isDraft = draftTag != null,
|
||||
@@ -2926,7 +2970,7 @@ class Account(
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String? = null,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -2944,7 +2988,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
draftTag = draftTag,
|
||||
signer = signer,
|
||||
) {
|
||||
|
||||
@@ -66,13 +66,13 @@ import com.vitorpamplona.quartz.events.WrappedEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import java.math.BigDecimal
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@Stable
|
||||
@@ -618,7 +618,7 @@ open class Note(
|
||||
private fun processZapAmountFromResponse(
|
||||
paymentRequest: Note,
|
||||
paymentResponse: Note?,
|
||||
continuation: CancellableContinuation<InvoiceAmount?>,
|
||||
continuation: Continuation<InvoiceAmount?>,
|
||||
signer: NostrSigner,
|
||||
) {
|
||||
val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent
|
||||
@@ -644,7 +644,7 @@ open class Note(
|
||||
private fun processZapAmountFromResponse(
|
||||
nwcRequest: LnZapPaymentRequestEvent,
|
||||
nwcResponse: LnZapPaymentResponseEvent,
|
||||
continuation: CancellableContinuation<InvoiceAmount?>,
|
||||
continuation: Continuation<InvoiceAmount?>,
|
||||
signer: NostrSigner,
|
||||
) {
|
||||
// if we can decrypt the reply
|
||||
|
||||
@@ -29,7 +29,7 @@ import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
|
||||
|
||||
class Blurhash(
|
||||
val blurhash: String,
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ class LightningAddressResolver {
|
||||
} else {
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(context, R.string.could_not_fetch_invoice_from, lnCallback),
|
||||
stringRes(context, R.string.could_not_fetch_invoice_from, urlBinder),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
class CombinedUploader
|
||||
+19
-96
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -28,53 +28,52 @@ import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMetadataRetriever.BitmapParams
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
|
||||
import com.vitorpamplona.amethyst.service.Blurhash
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: Dimension?,
|
||||
val blurHash: String?,
|
||||
val blurHash: Blurhash?,
|
||||
) {
|
||||
class UnableToDownload(
|
||||
val fileUrl: String,
|
||||
) : Exception()
|
||||
|
||||
companion object {
|
||||
suspend fun prepare(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: Dimension?,
|
||||
forceProxy: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
): Result<FileHeader> =
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
|
||||
prepare(imageData, mimeType, dimPrecomputed)
|
||||
} else {
|
||||
onError("Unable to download image from $fileUrl")
|
||||
Result.failure(UnableToDownload(fileUrl))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError(e.message ?: e.javaClass.simpleName)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: Dimension?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
): Result<FileHeader> =
|
||||
try {
|
||||
val hash = CryptoUtils.sha256(data).toHexKey()
|
||||
val size = data.size
|
||||
@@ -84,88 +83,13 @@ class FileHeader(
|
||||
val opt = BitmapFactory.Options()
|
||||
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
|
||||
|
||||
val intArray = IntArray(mBitmap.width * mBitmap.height)
|
||||
mBitmap.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
mBitmap.width,
|
||||
0,
|
||||
0,
|
||||
mBitmap.width,
|
||||
mBitmap.height,
|
||||
)
|
||||
|
||||
val dim = Dimension(mBitmap.width, mBitmap.height)
|
||||
|
||||
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
Pair(
|
||||
BlurHash.encode(
|
||||
intArray,
|
||||
mBitmap.width,
|
||||
mBitmap.height,
|
||||
9,
|
||||
(9 * (1 / aspectRatio)).roundToInt(),
|
||||
),
|
||||
dim,
|
||||
)
|
||||
} else if (aspectRatio < 1) {
|
||||
Pair(
|
||||
BlurHash.encode(
|
||||
intArray,
|
||||
mBitmap.width,
|
||||
mBitmap.height,
|
||||
(9 * aspectRatio).roundToInt(),
|
||||
9,
|
||||
),
|
||||
dim,
|
||||
)
|
||||
} else {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
Pair(Blurhash(mBitmap.toBlurhash()), Dimension(mBitmap.width, mBitmap.height))
|
||||
} else if (mimeType?.startsWith("video/") == true) {
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
|
||||
|
||||
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
|
||||
|
||||
val blurhash =
|
||||
mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
|
||||
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
|
||||
|
||||
val intArray = IntArray(thumbnail.width * thumbnail.height)
|
||||
thumbnail.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
thumbnail.width,
|
||||
0,
|
||||
0,
|
||||
thumbnail.width,
|
||||
thumbnail.height,
|
||||
)
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
BlurHash.encode(
|
||||
intArray,
|
||||
thumbnail.width,
|
||||
thumbnail.height,
|
||||
9,
|
||||
(9 * (1 / aspectRatio)).roundToInt(),
|
||||
)
|
||||
} else if (aspectRatio < 1) {
|
||||
BlurHash.encode(
|
||||
intArray,
|
||||
thumbnail.width,
|
||||
thumbnail.height,
|
||||
(9 * aspectRatio).roundToInt(),
|
||||
9,
|
||||
)
|
||||
} else {
|
||||
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
|
||||
}
|
||||
}
|
||||
val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { Blurhash(it) }
|
||||
|
||||
if (newDim?.hasSize() == true) {
|
||||
Pair(blurhash, newDim)
|
||||
@@ -176,13 +100,12 @@ class FileHeader(
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
|
||||
Result.success(FileHeader(mimeType, hash, size, dim, blurHash))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError(e.message ?: e.javaClass.simpleName)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.uploads
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
|
||||
data class MediaUploadResult(
|
||||
// A publicly accessible URL to the BUD-01 GET /<sha256> endpoint (optionally with a file extension)
|
||||
val url: String?,
|
||||
// The sha256 hash of the blob
|
||||
val sha256: HexKey? = null,
|
||||
// The size of the blob in bytes
|
||||
val size: Long? = null,
|
||||
// (optional) The MIME type of the blob
|
||||
val type: String? = null,
|
||||
// upload time
|
||||
val uploaded: Long? = null,
|
||||
// dimensions
|
||||
val dimension: Dimension? = null,
|
||||
// magnet link
|
||||
val magnet: String? = null,
|
||||
// info hash
|
||||
val infohash: String? = null,
|
||||
// ipfs link
|
||||
val ipfs: String? = null,
|
||||
)
|
||||
+32
-80
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
package com.vitorpamplona.amethyst.service.uploads.blossom
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
@@ -29,15 +29,16 @@ import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.tryAndWait
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.service.HttpStatusMessages
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.randomChars
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import com.vitorpamplona.quartz.events.BlossomAuthorizationEvent
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
@@ -46,11 +47,8 @@ import okio.source
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class BlossomUploader(
|
||||
val account: Account?,
|
||||
) {
|
||||
class BlossomUploader {
|
||||
fun Context.getFileName(uri: Uri): String? =
|
||||
when (uri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
|
||||
@@ -71,13 +69,14 @@ class BlossomUploader(
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
serverBaseUrl: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
|
||||
context: Context,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val fileName = context.getFileName(uri)
|
||||
|
||||
@@ -103,31 +102,38 @@ class BlossomUploader(
|
||||
myContentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
server,
|
||||
serverBaseUrl,
|
||||
forceProxy,
|
||||
httpAuth,
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
fun encodeAuth(event: BlossomAuthorizationEvent): String {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
|
||||
return "Nostr $encodedNIP98Event"
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
hash: HexKey,
|
||||
length: Int,
|
||||
fileName: String?,
|
||||
baseFileName: String?,
|
||||
contentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: ServerName,
|
||||
serverBaseUrl: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
|
||||
context: Context,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val fileName = fileName ?: randomChars()
|
||||
val fileName = baseFileName ?: randomChars()
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val apiUrl = server.baseUrl.removeSuffix("/") + "/upload"
|
||||
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
|
||||
val requestBuilder = Request.Builder()
|
||||
@@ -143,12 +149,8 @@ class BlossomUploader(
|
||||
}
|
||||
}
|
||||
|
||||
authUploadHeader(
|
||||
hash,
|
||||
length.toLong(),
|
||||
alt?.let { "Uploading $it" } ?: "Uploading $fileName",
|
||||
)?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
httpAuth(hash, length.toLong(), alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let {
|
||||
requestBuilder.addHeader("Authorization", encodeAuth(it))
|
||||
}
|
||||
|
||||
contentType?.let { requestBuilder.addHeader("Content-Type", it) }
|
||||
@@ -186,23 +188,21 @@ class BlossomUploader(
|
||||
suspend fun delete(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
server: ServerName,
|
||||
serverBaseUrl: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
httpAuth: (hash: HexKey, alt: String) -> BlossomAuthorizationEvent?,
|
||||
context: Context,
|
||||
): Boolean {
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val apiUrl = server.baseUrl
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
|
||||
val apiUrl = serverBaseUrl
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
authDeleteHeader(
|
||||
hash,
|
||||
"Deleting $hash",
|
||||
)?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
httpAuth(hash, "Deleting $hash")?.let {
|
||||
requestBuilder.addHeader("Authorization", encodeAuth(it))
|
||||
}
|
||||
|
||||
val request =
|
||||
requestBuilder
|
||||
@@ -211,7 +211,7 @@ class BlossomUploader(
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
HttpClientManager.getHttpClient(forceProxy(apiUrl)).newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
return true
|
||||
} else {
|
||||
@@ -225,56 +225,8 @@ class BlossomUploader(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authUploadHeader(
|
||||
hash: String,
|
||||
size: Long,
|
||||
alt: String,
|
||||
): String? {
|
||||
val myAccount = account ?: return null
|
||||
return tryAndWait { continuation ->
|
||||
myAccount.createBlossomUploadAuth(hash, size, alt) {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||
continuation.resume("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authDeleteHeader(
|
||||
hash: String,
|
||||
alt: String,
|
||||
): String? {
|
||||
val myAccount = account ?: return null
|
||||
return tryAndWait { continuation ->
|
||||
myAccount.createBlossomDeleteAuth(hash, alt) {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||
continuation.resume("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseResults(body: String): MediaUploadResult {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, MediaUploadResult::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaUploadResult(
|
||||
// A publicly accessible URL to the BUD-01 GET /<sha256> endpoint (optionally with a file extension)
|
||||
val url: String?,
|
||||
// The sha256 hash of the blob
|
||||
val sha256: HexKey? = null,
|
||||
// The size of the blob in bytes
|
||||
val size: Long? = null,
|
||||
// (optional) The MIME type of the blob
|
||||
val type: String? = null,
|
||||
// upload time
|
||||
val uploaded: Long? = null,
|
||||
// dimensions
|
||||
val dimension: Dimension? = null,
|
||||
// magnet link
|
||||
val magnet: String? = null,
|
||||
val infohash: String? = null,
|
||||
// ipfs link
|
||||
val ipfs: String? = null,
|
||||
)
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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.nip96
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class Nip96Result(
|
||||
val status: String? = null,
|
||||
val message: String? = null,
|
||||
@JsonProperty("processing_url")
|
||||
val processingUrl: String? = null,
|
||||
val percentage: Int? = null,
|
||||
@JsonProperty("nip94_event")
|
||||
val nip94Event: PartialEvent? = null,
|
||||
)
|
||||
|
||||
class PartialEvent(
|
||||
val tags: Array<Array<String>>? = null,
|
||||
val content: String? = null,
|
||||
)
|
||||
|
||||
data class DeleteResult(
|
||||
val status: String?,
|
||||
val message: String?,
|
||||
)
|
||||
+49
-94
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
package com.vitorpamplona.amethyst.service.uploads.nip96
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
@@ -26,17 +26,17 @@ import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.tryAndWait
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.service.HttpStatusMessages
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent
|
||||
import kotlinx.coroutines.delay
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
@@ -46,47 +46,44 @@ import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
|
||||
class Nip96Uploader(
|
||||
val account: Account?,
|
||||
) {
|
||||
class Nip96Uploader {
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
serverBaseUrl: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
|
||||
context: Context,
|
||||
): MediaUploadResult {
|
||||
val serverInfo =
|
||||
Nip96Retriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
forceProxy(server.baseUrl),
|
||||
)
|
||||
) = uploadImage(
|
||||
uri,
|
||||
contentType,
|
||||
size,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
ServerInfoRetriever().loadInfo(serverBaseUrl, forceProxy(serverBaseUrl)),
|
||||
forceProxy,
|
||||
onProgress,
|
||||
httpAuth,
|
||||
context,
|
||||
)
|
||||
|
||||
return uploadImage(
|
||||
uri,
|
||||
contentType,
|
||||
size,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
serverInfo,
|
||||
contentResolver,
|
||||
forceProxy,
|
||||
onProgress,
|
||||
context,
|
||||
)
|
||||
}
|
||||
fun ContentResolver.querySize(uri: Uri) =
|
||||
query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
}
|
||||
|
||||
fun fileSize(uri: Uri) = runCatching { uri.toFile().length() }.getOrNull()
|
||||
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
@@ -94,25 +91,19 @@ class Nip96Uploader(
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
contentResolver: ContentResolver,
|
||||
server: ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
|
||||
context: Context,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0
|
||||
|
||||
val length =
|
||||
size
|
||||
?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
}
|
||||
?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
checkNotNull(imageInputStream) { "Can't open the image input stream" }
|
||||
|
||||
@@ -125,6 +116,7 @@ class Nip96Uploader(
|
||||
server,
|
||||
forceProxy,
|
||||
onProgress,
|
||||
httpAuth,
|
||||
context,
|
||||
)
|
||||
}
|
||||
@@ -135,16 +127,16 @@ class Nip96Uploader(
|
||||
contentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
server: ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
|
||||
context: Context,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val fileName = randomChars()
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
|
||||
val requestBuilder = Request.Builder()
|
||||
@@ -173,7 +165,7 @@ class Nip96Uploader(
|
||||
},
|
||||
).build()
|
||||
|
||||
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", encodeAuth(it)) }
|
||||
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
@@ -259,11 +251,17 @@ class Nip96Uploader(
|
||||
)
|
||||
}
|
||||
|
||||
fun encodeAuth(event: HTTPAuthorizationEvent): String {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
|
||||
return "Nostr $encodedNIP98Event"
|
||||
}
|
||||
|
||||
suspend fun delete(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
server: ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
httpAuth: (String, String, ByteArray?) -> HTTPAuthorizationEvent,
|
||||
context: Context,
|
||||
): Boolean {
|
||||
val extension =
|
||||
@@ -273,7 +271,7 @@ class Nip96Uploader(
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
httpAuth(server.apiUrl, "DELETE", null)?.let { requestBuilder.addHeader("Authorization", encodeAuth(it)) }
|
||||
|
||||
val request =
|
||||
requestBuilder
|
||||
@@ -302,7 +300,7 @@ class Nip96Uploader(
|
||||
|
||||
private suspend fun waitProcessing(
|
||||
result: Nip96Result,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
server: ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
): MediaUploadResult {
|
||||
@@ -338,54 +336,11 @@ class Nip96Uploader(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nip98Header(url: String): String? =
|
||||
tryAndWait { continuation ->
|
||||
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
|
||||
}
|
||||
|
||||
fun nip98Header(
|
||||
url: String,
|
||||
method: String,
|
||||
file: ByteArray? = null,
|
||||
onReady: (String?) -> Unit,
|
||||
) {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount == null) {
|
||||
onReady(null)
|
||||
return
|
||||
}
|
||||
|
||||
myAccount.createHTTPAuthorization(url, method, file) {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||
onReady("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
|
||||
data class DeleteResult(
|
||||
val status: String?,
|
||||
val message: String?,
|
||||
)
|
||||
|
||||
private fun parseDeleteResults(body: String): DeleteResult {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, DeleteResult::class.java)
|
||||
}
|
||||
|
||||
data class Nip96Result(
|
||||
val status: String? = null,
|
||||
val message: String? = null,
|
||||
@JsonProperty("processing_url") val processingUrl: String? = null,
|
||||
val percentage: Int? = null,
|
||||
@JsonProperty("nip94_event") val nip94Event: PartialEvent? = null,
|
||||
)
|
||||
|
||||
class PartialEvent(
|
||||
val tags: Array<Array<String>>? = null,
|
||||
val content: String? = null,
|
||||
)
|
||||
|
||||
private fun parseResults(body: String): Nip96Result {
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, Nip96Result::class.java)
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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.nip96
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
typealias PlanName = String
|
||||
|
||||
typealias MimeType = String
|
||||
|
||||
data class ServerInfo(
|
||||
@JsonProperty("api_url")
|
||||
val apiUrl: String,
|
||||
@JsonProperty("download_url")
|
||||
val downloadUrl: String? = null,
|
||||
@JsonProperty("delegated_to_url")
|
||||
val delegatedToUrl: String? = null,
|
||||
@JsonProperty("supported_nips")
|
||||
val supportedNips: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("tos_url") val
|
||||
tosUrl: String? = null,
|
||||
@JsonProperty("content_types") val
|
||||
contentTypes: ArrayList<MimeType> = arrayListOf(),
|
||||
@JsonProperty("plans") val
|
||||
plans: Map<PlanName, Plan> = mapOf(),
|
||||
)
|
||||
|
||||
data class Plan(
|
||||
@JsonProperty("name") val
|
||||
name: String? = null,
|
||||
@JsonProperty("is_nip98_required") val
|
||||
isNip98Required: Boolean? = null,
|
||||
@JsonProperty("url") val
|
||||
url: String? = null,
|
||||
@JsonProperty("max_byte_size") val
|
||||
maxByteSize: Long? = null,
|
||||
@JsonProperty("file_expiration") val
|
||||
fileExpiration: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("media_transformations")
|
||||
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
|
||||
)
|
||||
+3
-43
@@ -18,55 +18,19 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.service.uploads.nip96
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Request
|
||||
import java.net.URI
|
||||
import java.net.URL
|
||||
|
||||
object Nip96MediaServers {
|
||||
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
||||
|
||||
suspend fun load(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
): Nip96Retriever.ServerInfo {
|
||||
val cached = cache[url]
|
||||
if (cached != null) return cached
|
||||
|
||||
val fetched = Nip96Retriever().loadInfo(url, forceProxy)
|
||||
cache[url] = fetched
|
||||
return fetched
|
||||
}
|
||||
}
|
||||
|
||||
class Nip96Retriever {
|
||||
data class ServerInfo(
|
||||
@JsonProperty("api_url") val apiUrl: String,
|
||||
@JsonProperty("download_url") val downloadUrl: String? = null,
|
||||
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
|
||||
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("tos_url") val tosUrl: String? = null,
|
||||
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
|
||||
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf(),
|
||||
)
|
||||
|
||||
data class Plan(
|
||||
@JsonProperty("name") val name: String? = null,
|
||||
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
|
||||
@JsonProperty("url") val url: String? = null,
|
||||
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
|
||||
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("media_transformations")
|
||||
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
|
||||
)
|
||||
|
||||
class ServerInfoRetriever {
|
||||
fun parse(
|
||||
baseUrl: String,
|
||||
body: String,
|
||||
@@ -138,7 +102,3 @@ class Nip96Retriever {
|
||||
potentialyRelativeUrl
|
||||
}
|
||||
}
|
||||
|
||||
typealias PlanName = String
|
||||
|
||||
typealias MimeType = String
|
||||
@@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
@@ -113,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -302,7 +304,7 @@ fun EditPostView(
|
||||
myUrlPreview,
|
||||
mimeType = null,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
@@ -326,22 +328,22 @@ fun EditPostView(
|
||||
}
|
||||
}
|
||||
|
||||
val url = postViewModel.contentToAddUrl
|
||||
if (url != null) {
|
||||
if (postViewModel.mediaToUpload.isNotEmpty()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
postViewModel.mediaToUpload,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||
if (server.type != ServerType.NIP95) {
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||
}
|
||||
},
|
||||
onCancel = { postViewModel.contentToAddUrl = null },
|
||||
onDelete = postViewModel::deleteMediaToUpload,
|
||||
onCancel = { postViewModel.mediaToUpload = persistentListOf() },
|
||||
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
@@ -468,7 +470,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
|
||||
.height(50.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
UploadFromGallery(
|
||||
SelectFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
|
||||
+85
-210
@@ -21,8 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -31,29 +29,30 @@ import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@@ -65,7 +64,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
|
||||
var subject by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
|
||||
var iMetaAttachments by mutableStateOf<List<IMetaTag>>(emptyList())
|
||||
var nip95attachments by
|
||||
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
@@ -78,7 +77,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(null)
|
||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
@@ -103,7 +102,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
this.account = accountViewModel.account
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
contentToAddUrl = null
|
||||
mediaToUpload = persistentListOf()
|
||||
|
||||
message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
|
||||
urlPreview = findUrlInMessage()
|
||||
@@ -149,7 +148,6 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun upload(
|
||||
galleryUri: Uri,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: Int,
|
||||
@@ -158,119 +156,77 @@ open class EditPostViewModel : ViewModel() {
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
val myAccount = account ?: return
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val contentType = contentResolver.getType(galleryUri)
|
||||
viewModelScope.launch {
|
||||
isUploadingImage = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
MediaCompressor()
|
||||
.compress(
|
||||
galleryUri,
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.type == ServerType.NIP95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
contentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context,
|
||||
)
|
||||
val jobs =
|
||||
mediaToUpload.map { myGalleryUri ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
myGalleryUri.orchestrator.upload(
|
||||
myGalleryUri.media.uri,
|
||||
myGalleryUri.media.mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
jobs.joinAll()
|
||||
|
||||
val allGood =
|
||||
mediaToUpload.mapNotNull {
|
||||
it.orchestrator.progressState.value as? UploadingState.Finished
|
||||
}
|
||||
|
||||
if (allGood.size == mediaToUpload.size) {
|
||||
allGood.forEach {
|
||||
if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
}
|
||||
} else if (server.type == ServerType.NIP96) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
|
||||
},
|
||||
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
|
||||
)
|
||||
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(it.result.url)
|
||||
.apply {
|
||||
hash(it.result.fileHeader.hash)
|
||||
size(it.result.fileHeader.size)
|
||||
it.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
it.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
it.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
it.result.magnet?.let { magnet(it) }
|
||||
it.result.originalHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
// TODO: Support Reasons on images
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
message = message.insertUrlAtCursor(it.result.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
}
|
||||
|
||||
mediaToUpload = persistentListOf()
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +236,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
|
||||
editedFromNote = null
|
||||
|
||||
contentToAddUrl = null
|
||||
mediaToUpload = persistentListOf()
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
|
||||
@@ -348,94 +304,13 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun canPost(): Boolean =
|
||||
message.text.isNotBlank() &&
|
||||
!isUploadingImage &&
|
||||
!wantsInvoice &&
|
||||
contentToAddUrl == null
|
||||
fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && mediaToUpload.isEmpty()
|
||||
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: MediaUploadResult,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
if (uploadingResult.url.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
fileUrl = uploadingResult.url,
|
||||
mimeType = uploadingResult.type ?: localContentType,
|
||||
dimPrecomputed = uploadingResult.dimension,
|
||||
forceProxy = forceProxy(uploadingResult.url),
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
|
||||
isUploadingImage = false
|
||||
nip94attachments = nip94attachments + event
|
||||
|
||||
message = message.insertUrlAtCursor(uploadingResult.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
||||
}
|
||||
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
mimeType,
|
||||
null,
|
||||
onReady = {
|
||||
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectImage(uri: Uri) {
|
||||
contentToAddUrl = uri
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class ImageDownloader {
|
||||
suspend fun waitAndGetImage(
|
||||
imageUrl: String,
|
||||
forceProxy: Boolean,
|
||||
): ByteArray? {
|
||||
var imageData: ByteArray? = null
|
||||
var tentatives = 0
|
||||
|
||||
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
|
||||
while (imageData == null && tentatives < 15) {
|
||||
imageData =
|
||||
try {
|
||||
// TODO: Migrate to OkHttp
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc =
|
||||
if (forceProxy) {
|
||||
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
huc.instanceFollowRedirects = true
|
||||
var responseCode = huc.responseCode
|
||||
|
||||
if (responseCode in 300..400) {
|
||||
val newUrl: String = huc.getHeaderField("Location")
|
||||
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc =
|
||||
if (forceProxy) {
|
||||
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
responseCode = huc.responseCode
|
||||
}
|
||||
|
||||
if (responseCode in 200..300) {
|
||||
huc.inputStream.use { it.readBytes() }
|
||||
} else {
|
||||
tentatives++
|
||||
delay(1000)
|
||||
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
tentatives++
|
||||
delay(1000)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
}
|
||||
@@ -21,336 +21,204 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Stable
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
var mediaType by mutableStateOf<String?>(null)
|
||||
|
||||
var selectedServer by mutableStateOf<ServerName?>(null)
|
||||
var alt by mutableStateOf("")
|
||||
var caption by mutableStateOf("")
|
||||
var sensitiveContent by mutableStateOf(false)
|
||||
|
||||
// Images and Videos
|
||||
var galleryUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
var uploadingPercentage = mutableStateOf(0.0f)
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||
var mediaQualitySlider by mutableIntStateOf(1)
|
||||
|
||||
open fun load(
|
||||
account: Account,
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
uris: ImmutableList<SelectedMedia>,
|
||||
) {
|
||||
this.caption = ""
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
||||
this.selectedServer = defaultServer()
|
||||
}
|
||||
|
||||
fun isImage(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url)
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
mediaQuality: Int,
|
||||
onError: (String) -> Unit = {},
|
||||
) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val myGalleryUri = galleryUri ?: return
|
||||
val myAccount = account ?: return
|
||||
if (relayList.isEmpty()) return
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
val contentType = contentResolver.getType(myGalleryUri)
|
||||
viewModelScope.launch {
|
||||
isUploadingImage = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Compress"
|
||||
MediaCompressor()
|
||||
.compress(
|
||||
myGalleryUri,
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (serverToUse.type == ServerType.NIP95) {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Loading"
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
contentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
relayList = relayList,
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
val jobs =
|
||||
mediaToUpload.map { myGalleryUri ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
myGalleryUri.orchestrator.upload(
|
||||
myGalleryUri.media.uri,
|
||||
myGalleryUri.media.mimeType,
|
||||
caption,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
|
||||
serverToUse,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
jobs.joinAll()
|
||||
|
||||
val allGood =
|
||||
mediaToUpload.mapNotNull {
|
||||
it.orchestrator.progressState.value as? UploadingState.Finished
|
||||
}
|
||||
|
||||
if (allGood.size == mediaToUpload.size) {
|
||||
// It all finished successfully
|
||||
val nip95s =
|
||||
allGood.mapNotNull {
|
||||
it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result
|
||||
}
|
||||
|
||||
val videosAndOthers =
|
||||
allGood.mapNotNull {
|
||||
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||
if (map != null && !isImage(map.url, map.fileHeader.mimeType)) {
|
||||
map
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val imageUrls =
|
||||
allGood
|
||||
.mapNotNull {
|
||||
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||
if (map != null && isImage(map.url, map.fileHeader.mimeType)) {
|
||||
Pair(map.url, map.fileHeader)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
?: run {
|
||||
viewModelScope.launch {
|
||||
onError(stringRes(context, R.string.could_not_open_the_compressed_file))
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
}.toMap()
|
||||
|
||||
val nip95jobs =
|
||||
nip95s.map {
|
||||
// upload each file as an individual nip95 event.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withTimeoutOrNull(30000) {
|
||||
suspendCoroutine { continuation ->
|
||||
account?.createNip95(it.bytes, headerInfo = it.fileHeader, caption, sensitiveContent) { nip95 ->
|
||||
account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
|
||||
continuation.resume(true)
|
||||
}
|
||||
}
|
||||
} else if (serverToUse.type == ServerType.NIP96) {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Uploading"
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = { percent: Float ->
|
||||
uploadingPercentage.value = 0.2f + (0.2f * percent)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
relayList = relayList,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
|
||||
}
|
||||
}
|
||||
} else if (serverToUse.type == ServerType.Blossom) {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Uploading"
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
relayList = relayList,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
onError(stringRes(context, R.string.error_when_compressing_media, it))
|
||||
},
|
||||
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
|
||||
)
|
||||
}
|
||||
|
||||
val videoJobs =
|
||||
videosAndOthers.map {
|
||||
// upload each file as an individual nip95 event.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withTimeoutOrNull(30000) {
|
||||
suspendCoroutine { continuation ->
|
||||
account?.sendHeader(
|
||||
it.url,
|
||||
it.magnet,
|
||||
it.fileHeader,
|
||||
caption,
|
||||
sensitiveContent,
|
||||
it.originalHash,
|
||||
relayList,
|
||||
) {
|
||||
continuation.resume(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val imageJobs =
|
||||
listOf(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withTimeoutOrNull(30000) {
|
||||
suspendCoroutine { continuation ->
|
||||
account?.sendAllAsOnePictureEvent(
|
||||
imageUrls,
|
||||
caption,
|
||||
sensitiveContent,
|
||||
relayList,
|
||||
) {
|
||||
continuation.resume(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
nip95jobs.joinAll()
|
||||
videoJobs.joinAll()
|
||||
imageJobs.joinAll()
|
||||
|
||||
onceUploaded()
|
||||
cancelModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
galleryUri = null
|
||||
open fun cancelModel() {
|
||||
mediaToUpload = persistentListOf()
|
||||
isUploadingImage = false
|
||||
mediaType = null
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
alt = ""
|
||||
caption = ""
|
||||
selectedServer = defaultServer()
|
||||
}
|
||||
|
||||
fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: MediaUploadResult,
|
||||
localContentType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (uploadingResult.url.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
return
|
||||
}
|
||||
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadingResult.url, forceProxy(uploadingResult.url))
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
FileHeader.prepare(
|
||||
data = imageData,
|
||||
mimeType = uploadingResult.type ?: localContentType,
|
||||
dimPrecomputed = uploadingResult.dimension,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(
|
||||
uploadingResult.url,
|
||||
uploadingResult.magnet,
|
||||
it,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
uploadingResult.sha256,
|
||||
relayList,
|
||||
) {
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.could_not_prepare_local_file_to_upload, it))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.could_not_download_from_the_server))
|
||||
}
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
|
||||
}
|
||||
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 0.30f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
mimeType,
|
||||
null,
|
||||
onReady = {
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.40f
|
||||
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.00f
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
onError(stringRes(context, R.string.could_not_prepare_local_file_to_upload, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun canPost(): Boolean = !isUploadingImage && mediaToUpload.isNotEmpty() && selectedServer != null
|
||||
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
|
||||
|
||||
@@ -20,25 +20,17 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -57,15 +49,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -73,11 +62,11 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||
@@ -85,41 +74,35 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingSwitchItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewMediaView(
|
||||
uri: Uri,
|
||||
uris: ImmutableList<SelectedMedia>,
|
||||
onClose: () -> Unit,
|
||||
postViewModel: NewMediaModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
LaunchedEffect(uris) {
|
||||
postViewModel.load(account, uris)
|
||||
}
|
||||
|
||||
var showRelaysDialog by remember { mutableStateOf(false) }
|
||||
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||
var mediaQualitySlider by remember { mutableIntStateOf(1) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties =
|
||||
@@ -158,9 +141,8 @@ fun NewMediaView(
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context, relayList, mediaQualitySlider) {
|
||||
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
}
|
||||
postViewModel.upload(context, relayList)
|
||||
// accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
postViewModel.selectedServer?.let {
|
||||
if (it.type != ServerType.NIP95) {
|
||||
account.settings.changeDefaultFileServer(it)
|
||||
@@ -176,7 +158,7 @@ fun NewMediaView(
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
postViewModel.cancel()
|
||||
postViewModel.cancelModel()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
@@ -206,69 +188,9 @@ fun NewMediaView(
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).verticalScroll(scrollState),
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
|
||||
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
|
||||
ImageVideoPost(postViewModel, accountViewModel)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(context, R.string.media_compression_quality_label),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(context, R.string.media_compression_quality_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
when (mediaQualitySlider) {
|
||||
0 -> stringRes(R.string.media_compression_quality_low)
|
||||
1 -> stringRes(R.string.media_compression_quality_medium)
|
||||
2 -> stringRes(R.string.media_compression_quality_high)
|
||||
3 -> stringRes(R.string.media_compression_quality_uncompressed)
|
||||
else -> stringRes(R.string.media_compression_quality_medium)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
Slider(
|
||||
value = mediaQualitySlider.toFloat(),
|
||||
onValueChange = { mediaQualitySlider = it.toInt() },
|
||||
valueRange = 0f..3f,
|
||||
steps = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,70 +218,41 @@ fun ImageVideoPost(
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
ShowImageUploadGallery(
|
||||
postViewModel.mediaToUpload,
|
||||
postViewModel::deleteMediaToUpload,
|
||||
accountViewModel,
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
) {
|
||||
if (postViewModel.isImage() == true) {
|
||||
AsyncImage(
|
||||
model = postViewModel.galleryUri.toString(),
|
||||
contentDescription = postViewModel.galleryUri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.add_caption)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp),
|
||||
maxLines = 10,
|
||||
value = postViewModel.caption,
|
||||
onValueChange = { postViewModel.caption = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.add_caption_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
)
|
||||
|
||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.galleryUri?.let {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingSwitchItem(
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
checked = postViewModel.sensitiveContent,
|
||||
onCheckedChange = { postViewModel.sensitiveContent = it },
|
||||
)
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.padding(top = 4.dp).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(
|
||||
videoUri = it.toString(),
|
||||
mimeType = postViewModel.mediaType,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = false,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
SettingsRow(R.string.file_server, R.string.file_server_description) {
|
||||
TextSpinner(
|
||||
label = stringRes(id = R.string.file_server),
|
||||
label = "",
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
|
||||
@@ -367,42 +260,47 @@ fun ImageVideoPost(
|
||||
?: fileServers[0].name,
|
||||
options = fileServerOptions,
|
||||
onSelect = { postViewModel.selectedServer = fileServers[it] },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
SettingSwitchItem(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
checked = postViewModel.sensitiveContent,
|
||||
onCheckedChange = { postViewModel.sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description,
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_label),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.media_compression_quality_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.content_description)) },
|
||||
modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = postViewModel.alt,
|
||||
onValueChange = { postViewModel.alt = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
when (postViewModel.mediaQualitySlider) {
|
||||
0 -> stringRes(R.string.media_compression_quality_low)
|
||||
1 -> stringRes(R.string.media_compression_quality_medium)
|
||||
2 -> stringRes(R.string.media_compression_quality_high)
|
||||
3 -> stringRes(R.string.media_compression_quality_uncompressed)
|
||||
else -> stringRes(R.string.media_compression_quality_medium)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
Slider(
|
||||
value = postViewModel.mediaQualitySlider.toFloat(),
|
||||
onValueChange = { postViewModel.mediaQualitySlider = it.toInt() },
|
||||
valueRange = 0f..3f,
|
||||
steps = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+103
-226
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -35,28 +34,27 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.service.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
@@ -67,7 +65,6 @@ import com.vitorpamplona.quartz.events.CommentEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
@@ -79,10 +76,14 @@ import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.events.findURLs
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
@@ -108,7 +109,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
var pTags by mutableStateOf<List<User>?>(null)
|
||||
var eTags by mutableStateOf<List<Note>?>(null)
|
||||
|
||||
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
|
||||
var iMetaAttachments by mutableStateOf<List<IMetaTag>>(emptyList())
|
||||
var nip95attachments by
|
||||
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
@@ -126,7 +127,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
var subject by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(null)
|
||||
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
|
||||
|
||||
// Polls
|
||||
var canUsePoll by mutableStateOf(false)
|
||||
@@ -246,7 +247,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
contentToAddUrl = null
|
||||
mediaToUpload = persistentListOf()
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||
@@ -330,7 +331,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
|
||||
contentToAddUrl = null
|
||||
mediaToUpload = persistentListOf()
|
||||
|
||||
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
|
||||
forwardZapTo = Split()
|
||||
@@ -553,9 +554,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
|
||||
// Doesn't send as nip94 yet because we don't know if it makes sense.
|
||||
// usedAttachments.forEach { account?.sendHeader(it, relayList, {}) }
|
||||
val usedAttachments = iMetaAttachments.filter { it.url in urls.toSet() }
|
||||
|
||||
val replyingTo = originalNote
|
||||
|
||||
@@ -565,7 +564,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
replyingTo = replyingTo,
|
||||
directMentionsUsers = tagger.directMentionsUsers,
|
||||
directMentionsNotes = tagger.directMentionsNotes,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
geohash = geoHash,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
@@ -580,7 +579,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
replyingTo = originalNote,
|
||||
directMentionsUsers = tagger.directMentionsUsers,
|
||||
directMentionsNotes = tagger.directMentionsNotes,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
@@ -598,7 +597,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
@@ -611,7 +610,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
@@ -625,7 +624,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (originalNote?.event is ChatMessageEvent) {
|
||||
@@ -647,7 +646,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
zapReceiver = zapReceiver,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (!dmUsers.isNullOrEmpty()) {
|
||||
@@ -662,7 +661,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
zapReceiver = zapReceiver,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
@@ -675,7 +674,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
zapReceiver = zapReceiver,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
@@ -716,7 +715,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (originalNote?.event is TorrentCommentEvent) {
|
||||
@@ -755,7 +754,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
@@ -784,7 +783,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
@@ -803,7 +802,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (wantsProduct) {
|
||||
@@ -822,7 +821,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
@@ -859,7 +858,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
@@ -867,7 +866,6 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun upload(
|
||||
galleryUri: Uri,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
mediaQuality: Int,
|
||||
@@ -876,119 +874,77 @@ open class NewPostViewModel : ViewModel() {
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
val myAccount = account ?: return
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val contentType = contentResolver.getType(galleryUri)
|
||||
viewModelScope.launch {
|
||||
isUploadingImage = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
MediaCompressor()
|
||||
.compress(
|
||||
galleryUri,
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.type == ServerType.NIP95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
contentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context,
|
||||
)
|
||||
val jobs =
|
||||
mediaToUpload.map { myGalleryUri ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
myGalleryUri.orchestrator.upload(
|
||||
myGalleryUri.media.uri,
|
||||
myGalleryUri.media.mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
jobs.joinAll()
|
||||
|
||||
val allGood =
|
||||
mediaToUpload.mapNotNull {
|
||||
it.orchestrator.progressState.value as? UploadingState.Finished
|
||||
}
|
||||
|
||||
if (allGood.size == mediaToUpload.size) {
|
||||
allGood.forEach {
|
||||
if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
}
|
||||
} else if (server.type == ServerType.NIP96) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
} else if (server.type == ServerType.Blossom) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
context = context,
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
|
||||
},
|
||||
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
|
||||
)
|
||||
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(it.result.url)
|
||||
.apply {
|
||||
hash(it.result.fileHeader.hash)
|
||||
size(it.result.fileHeader.size)
|
||||
it.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
it.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
it.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
it.result.magnet?.let { magnet(it) }
|
||||
it.result.originalHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
// TODO: Support Reasons on images
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
message = message.insertUrlAtCursor(it.result.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
}
|
||||
|
||||
mediaToUpload = persistentListOf()
|
||||
}
|
||||
|
||||
isUploadingImage = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,7 +955,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
|
||||
forkedFromNote = null
|
||||
|
||||
contentToAddUrl = null
|
||||
mediaToUpload = persistentListOf()
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
pTags = null
|
||||
@@ -1047,6 +1003,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
|
||||
}
|
||||
|
||||
open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
|
||||
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
@@ -1210,97 +1170,14 @@ open class NewPostViewModel : ViewModel() {
|
||||
!category.text.isNullOrBlank()
|
||||
)
|
||||
) &&
|
||||
contentToAddUrl == null
|
||||
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: MediaUploadResult,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
if (uploadingResult.url.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
fileUrl = uploadingResult.url,
|
||||
mimeType = uploadingResult.type ?: localContentType,
|
||||
dimPrecomputed = uploadingResult.dimension,
|
||||
forceProxy = forceProxy(uploadingResult.url),
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
|
||||
isUploadingImage = false
|
||||
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
|
||||
|
||||
message = message.insertUrlAtCursor(uploadingResult.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
saveDraft()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
mediaToUpload.isEmpty()
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onError: (message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
mimeType,
|
||||
null,
|
||||
onReady = {
|
||||
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
saveDraft()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectImage(uri: Uri) {
|
||||
contentToAddUrl = uri
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
|
||||
}
|
||||
|
||||
fun locationFlow(): StateFlow<LocationState.LocationResult> {
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
|
||||
@@ -151,7 +152,7 @@ fun NewUserMetadataView(
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
SelectSingleFromGallery(
|
||||
isUploading = postViewModel.isUploadingImageForPicture,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
@@ -176,7 +177,7 @@ fun NewUserMetadataView(
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
SelectSingleFromGallery(
|
||||
isUploading = postViewModel.isUploadingImageForBanner,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
|
||||
+47
-66
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -29,9 +28,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -132,7 +132,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun uploadForPicture(
|
||||
uri: Uri,
|
||||
uri: SelectedMedia,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
@@ -148,7 +148,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun uploadForBanner(
|
||||
uri: Uri,
|
||||
uri: SelectedMedia,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
@@ -164,7 +164,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun upload(
|
||||
galleryUri: Uri,
|
||||
galleryUri: SelectedMedia,
|
||||
context: Context,
|
||||
onUploading: (Boolean) -> Unit,
|
||||
onUploaded: (String) -> Unit,
|
||||
@@ -172,67 +172,48 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
MediaCompressor()
|
||||
.compress(
|
||||
galleryUri,
|
||||
contentResolver.getType(galleryUri),
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.settings.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
} else {
|
||||
BlossomUploader(account)
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.settings.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
try {
|
||||
val result =
|
||||
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
|
||||
Nip96Uploader().uploadImage(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
onProgress = {},
|
||||
httpAuth = account::createHTTPAuthorization,
|
||||
context = context,
|
||||
)
|
||||
} else {
|
||||
BlossomUploader().uploadImage(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
httpAuth = account::createBlossomUploadAuth,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
|
||||
if (result.url != null) {
|
||||
onUploading(false)
|
||||
onUploaded(result.url)
|
||||
} else {
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
onUploading(false)
|
||||
|
||||
onError(stringRes(context, R.string.error_when_compressing_media), stringRes(context, it))
|
||||
},
|
||||
// Use MEDIUM quality as default
|
||||
mediaQuality = CompressorQuality.MEDIUM,
|
||||
)
|
||||
if (result.url != null) {
|
||||
onUploading(false)
|
||||
onUploaded(result.url)
|
||||
} else {
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class ImageDownloader {
|
||||
suspend fun waitAndGetImage(
|
||||
imageUrl: String,
|
||||
forceProxy: Boolean,
|
||||
): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
var imageData: ByteArray? = null
|
||||
var tentatives = 0
|
||||
|
||||
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
|
||||
while (imageData == null && tentatives < 15) {
|
||||
imageData =
|
||||
try {
|
||||
tryGetTheImage(imageUrl, forceProxy)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
if (imageData == null) {
|
||||
tentatives++
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext imageData
|
||||
}
|
||||
|
||||
private suspend fun tryGetTheImage(
|
||||
imageUrl: String,
|
||||
forceProxy: Boolean,
|
||||
): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
// TODO: Migrate to OkHttp
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc =
|
||||
if (forceProxy) {
|
||||
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
huc.instanceFollowRedirects = true
|
||||
var responseCode = huc.responseCode
|
||||
|
||||
if (responseCode in 300..400) {
|
||||
val newUrl: String = huc.getHeaderField("Location")
|
||||
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc =
|
||||
if (forceProxy) {
|
||||
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
responseCode = huc.responseCode
|
||||
}
|
||||
|
||||
return@withContext if (responseCode in 200..300) {
|
||||
huc.inputStream.use { it.readBytes() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
-100
@@ -18,78 +18,93 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Stable
|
||||
class SelectedMedia(
|
||||
val uri: Uri,
|
||||
val mimeType: String?,
|
||||
) {
|
||||
fun isImage() = mimeType?.startsWith("image")
|
||||
|
||||
fun isVideo() = mimeType?.startsWith("video")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UploadFromGallery(
|
||||
fun SelectFromGallery(
|
||||
isUploading: Boolean,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
onImageChosen: (Uri) -> Unit,
|
||||
onImageChosen: (ImmutableList<SelectedMedia>) -> Unit,
|
||||
) {
|
||||
var showGallerySelect by remember { mutableStateOf(false) }
|
||||
if (showGallerySelect) {
|
||||
GallerySelect(
|
||||
onImageUri = { uri ->
|
||||
showGallerySelect = false
|
||||
if (uri != null) {
|
||||
if (uri.isNotEmpty()) {
|
||||
onImageChosen(uri)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true }
|
||||
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadBoxButton(
|
||||
fun SelectSingleFromGallery(
|
||||
isUploading: Boolean,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
onImageChosen: (SelectedMedia) -> Unit,
|
||||
) {
|
||||
var showGallerySelect by remember { mutableStateOf(false) }
|
||||
if (showGallerySelect) {
|
||||
GallerySelectSingle(
|
||||
onImageUri = { media ->
|
||||
showGallerySelect = false
|
||||
if (media != null) {
|
||||
onImageChosen(media)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GallerySelectButton(
|
||||
isUploading: Boolean,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
@@ -115,84 +130,52 @@ private fun UploadBoxButton(
|
||||
}
|
||||
}
|
||||
|
||||
fun getPhotoUri(context: Context): Uri {
|
||||
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File
|
||||
.createTempFile(
|
||||
"JPEG_${timeStamp}_",
|
||||
".jpg",
|
||||
storageDir,
|
||||
).let {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val DefaultAnimationColors =
|
||||
listOf(
|
||||
Color(0xFF5851D8),
|
||||
Color(0xFF833AB4),
|
||||
Color(0xFFC13584),
|
||||
Color(0xFFE1306C),
|
||||
Color(0xFFFD1D1D),
|
||||
Color(0xFFF56040),
|
||||
Color(0xFFF77737),
|
||||
Color(0xFFFCAF45),
|
||||
Color(0xFFFFDC80),
|
||||
Color(0xFF5851D8),
|
||||
).toImmutableList()
|
||||
|
||||
@Composable
|
||||
fun LoadingAnimation(
|
||||
indicatorSize: Dp = 20.dp,
|
||||
circleColors: ImmutableList<Color> = DefaultAnimationColors,
|
||||
animationDuration: Int = 1000,
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
|
||||
val rotateAnimation by
|
||||
infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation =
|
||||
tween(
|
||||
durationMillis = animationDuration,
|
||||
easing = LinearEasing,
|
||||
),
|
||||
),
|
||||
label = "UploadGalleryUploadingAnimation",
|
||||
)
|
||||
|
||||
CircularProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size = indicatorSize)
|
||||
.rotate(degrees = rotateAnimation)
|
||||
.border(
|
||||
width = 4.dp,
|
||||
brush = Brush.sweepGradient(circleColors),
|
||||
shape = CircleShape,
|
||||
),
|
||||
progress = 1f,
|
||||
strokeWidth = 1.dp,
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) {
|
||||
fun GallerySelect(onImageUri: (ImmutableList<SelectedMedia>) -> Unit = {}) {
|
||||
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
onResult = { uri: Uri? ->
|
||||
onImageUri(uri)
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(10),
|
||||
onResult = { uris: List<Uri> ->
|
||||
onImageUri(
|
||||
uris
|
||||
.map {
|
||||
SelectedMedia(it, resolver.getType(it))
|
||||
}.toImmutableList(),
|
||||
)
|
||||
hasLaunched.set(false)
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LaunchGallery() {
|
||||
SideEffect {
|
||||
if (!hasLaunched.getAndSet(true)) {
|
||||
launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchGallery()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GallerySelectSingle(onImageUri: (SelectedMedia?) -> Unit = {}) {
|
||||
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
onResult = { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
onImageUri(SelectedMedia(uri, resolver.getType(uri)))
|
||||
} else {
|
||||
onImageUri(null)
|
||||
}
|
||||
|
||||
hasLaunched.set(false)
|
||||
},
|
||||
)
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
class SelectedMediaProcessing(
|
||||
val media: SelectedMedia,
|
||||
val orchestrator: UploadOrchestrator = UploadOrchestrator(),
|
||||
)
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.note.CloseIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ShowImageUploadGallery(
|
||||
list: ImmutableList<SelectedMediaProcessing>,
|
||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
AutoNonlazyGrid(list.size) {
|
||||
ShowImageUploadItem(list[it], onDelete, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bitmap thumbnail from video uri of the scheme type content://
|
||||
*/
|
||||
fun createVideoThumb(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
): Bitmap? {
|
||||
try {
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
mediaMetadataRetriever.setDataSource(context, uri)
|
||||
return mediaMetadataRetriever.frameAtTime
|
||||
} catch (ex: Exception) {
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", ex)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowImageUploadItem(
|
||||
item: SelectedMediaProcessing,
|
||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (item.media.isImage() == true) {
|
||||
AsyncImage(
|
||||
model = item.media.uri.toString(),
|
||||
contentDescription = item.media.uri.toString(),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
)
|
||||
} else if (item.media.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(key1 = item) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
bitmap = createVideoThumb(context, item.media.uri)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(
|
||||
videoUri = item.media.uri.toString(),
|
||||
mimeType = item.media.mimeType,
|
||||
roundedCorner = false,
|
||||
contentScale = ContentScale.Crop,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(
|
||||
videoUri = item.media.uri.toString(),
|
||||
mimeType = item.media.mimeType,
|
||||
roundedCorner = false,
|
||||
contentScale = ContentScale.Crop,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
OrchestratorOverlay(item.orchestrator) {
|
||||
onDelete(item)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OrchestratorOverlay(
|
||||
orchestrator: UploadOrchestrator,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val progress by orchestrator.progress.collectAsState()
|
||||
val progressState by orchestrator.progressState.collectAsState()
|
||||
|
||||
if (progressState is UploadingState.Ready) {
|
||||
DeleteButton(onDelete)
|
||||
} else {
|
||||
UploadingState(progress, progressState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteButton(onDelete: () -> Unit) {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopEnd,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(Size40Modifier, contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Size20Modifier,
|
||||
onClick = onDelete,
|
||||
) {
|
||||
CloseIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UploadingState(
|
||||
progress: Double,
|
||||
progressState: UploadingState,
|
||||
) {
|
||||
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress.toFloat(),
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
)
|
||||
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier =
|
||||
Size55Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
strokeWidth = 5.dp,
|
||||
)
|
||||
|
||||
val txt =
|
||||
when (progressState) {
|
||||
is UploadingState.Ready -> stringRes(R.string.uploading_state_ready)
|
||||
is UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing)
|
||||
is UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading)
|
||||
is UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing)
|
||||
is UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading)
|
||||
is UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing)
|
||||
is UploadingState.Finished -> stringRes(R.string.uploading_state_finished)
|
||||
is UploadingState.Error -> stringRes(R.string.uploading_state_error)
|
||||
}
|
||||
|
||||
Text(
|
||||
txt,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TakePictureButton(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) {
|
||||
var showCamera by remember { mutableStateOf(false) }
|
||||
if (showCamera) {
|
||||
TakePicture(
|
||||
onPictureTaken = { uri ->
|
||||
showCamera = false
|
||||
if (uri.isNotEmpty()) {
|
||||
onPictureTaken(uri)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
PictureButton { showCamera = true }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun TakePicture(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var cameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture(),
|
||||
) { success ->
|
||||
if (success) {
|
||||
cameraUri?.let {
|
||||
onPictureTaken(persistentListOf(SelectedMedia(it, "image/jpeg")))
|
||||
}
|
||||
} else {
|
||||
onPictureTaken(persistentListOf())
|
||||
}
|
||||
cameraUri = null
|
||||
}
|
||||
|
||||
val cameraPermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.CAMERA,
|
||||
onPermissionResult = {
|
||||
if (it) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
cameraUri = getPhotoUri(context)
|
||||
cameraUri?.let { launcher.launch(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
cameraUri = getPhotoUri(context)
|
||||
cameraUri?.let { launcher.launch(it) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PictureButton(onClick: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = stringRes(id = R.string.upload_image),
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPhotoUri(context: Context): Uri {
|
||||
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File
|
||||
.createTempFile(
|
||||
"JPEG_${timeStamp}_",
|
||||
".jpg",
|
||||
storageDir,
|
||||
).let {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState.UploadingFinalState
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressorResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
sealed class UploadingState {
|
||||
data object Ready : UploadingState()
|
||||
|
||||
data object Compressing : UploadingState()
|
||||
|
||||
data object Uploading : UploadingState()
|
||||
|
||||
data object ServerProcessing : UploadingState()
|
||||
|
||||
data object Downloading : UploadingState()
|
||||
|
||||
data object Hashing : UploadingState()
|
||||
|
||||
sealed class UploadingFinalState : UploadingState()
|
||||
|
||||
class Finished(
|
||||
val result: UploadOrchestrator.OrchestratorResult,
|
||||
) : UploadingFinalState()
|
||||
|
||||
class Error(
|
||||
val errorResource: Int,
|
||||
val params: Array<out String>,
|
||||
) : UploadingFinalState()
|
||||
}
|
||||
|
||||
class UploadOrchestrator {
|
||||
private val compressor = MediaCompressor()
|
||||
|
||||
val progress = MutableStateFlow(0.0)
|
||||
val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready)
|
||||
|
||||
val isUploading =
|
||||
progressState.map {
|
||||
progressState.value !is UploadingState.Ready && progressState.value !is UploadingState.Error && progressState.value !is UploadingState.Finished
|
||||
}
|
||||
|
||||
fun error(
|
||||
resId: Int,
|
||||
vararg params: String,
|
||||
) = UploadingState.Error(resId, params).also { updateState(0.0, it) }
|
||||
|
||||
fun finish(result: OrchestratorResult) = UploadingState.Finished(result).also { updateState(1.0, it) }
|
||||
|
||||
fun updateState(
|
||||
newProgress: Double,
|
||||
newState: UploadingState,
|
||||
) {
|
||||
progress.value = newProgress
|
||||
progressState.value = newState
|
||||
}
|
||||
|
||||
private fun uploadNIP95(
|
||||
fileUri: Uri,
|
||||
contentType: String?,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
updateState(0.4, UploadingState.Uploading)
|
||||
|
||||
val bytes =
|
||||
context.contentResolver.openInputStream(fileUri)?.use {
|
||||
it.readBytes()
|
||||
}
|
||||
|
||||
if (bytes != null) {
|
||||
if (bytes.size > 80000) {
|
||||
return error(R.string.media_too_big_for_nip95)
|
||||
}
|
||||
|
||||
updateState(0.8, UploadingState.Hashing)
|
||||
|
||||
val result =
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
contentType,
|
||||
null,
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
return finish(OrchestratorResult.NIP95Result(it, bytes))
|
||||
},
|
||||
onFailure = {
|
||||
return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
return error(R.string.could_not_open_the_compressed_file)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadNIP96(
|
||||
fileUri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
serverBaseUrl: String,
|
||||
account: Account,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
updateState(0.2, UploadingState.Uploading)
|
||||
return try {
|
||||
val result =
|
||||
Nip96Uploader().uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
onProgress = { percent: Float ->
|
||||
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
|
||||
},
|
||||
httpAuth = account::createHTTPAuthorization,
|
||||
context = context,
|
||||
)
|
||||
|
||||
verifyHeader(
|
||||
uploadResult = result,
|
||||
localContentType = contentType,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadBlossom(
|
||||
fileUri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
serverBaseUrl: String,
|
||||
account: Account,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
updateState(0.2, UploadingState.Uploading)
|
||||
return try {
|
||||
val result =
|
||||
BlossomUploader()
|
||||
.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
httpAuth = account::createBlossomUploadAuth,
|
||||
context = context,
|
||||
)
|
||||
|
||||
verifyHeader(
|
||||
uploadResult = result,
|
||||
localContentType = contentType,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun verifyHeader(
|
||||
uploadResult: MediaUploadResult,
|
||||
localContentType: String?,
|
||||
forceProxy: (String) -> Boolean,
|
||||
): UploadingFinalState {
|
||||
if (uploadResult.url.isNullOrBlank()) {
|
||||
return error(R.string.server_did_not_provide_a_url_after_uploading)
|
||||
}
|
||||
|
||||
updateState(0.6, UploadingState.Downloading)
|
||||
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadResult.url, forceProxy(uploadResult.url))
|
||||
|
||||
if (imageData != null) {
|
||||
updateState(0.8, UploadingState.Hashing)
|
||||
|
||||
val result =
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
uploadResult.type ?: localContentType,
|
||||
uploadResult.dimension,
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
return finish(OrchestratorResult.ServerResult(it, uploadResult.url, uploadResult.magnet, uploadResult.sha256))
|
||||
},
|
||||
onFailure = {
|
||||
return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
return error(R.string.could_not_download_from_the_server)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class OrchestratorResult {
|
||||
class NIP95Result(
|
||||
val fileHeader: FileHeader,
|
||||
val bytes: ByteArray,
|
||||
) : OrchestratorResult()
|
||||
|
||||
class ServerResult(
|
||||
val fileHeader: FileHeader,
|
||||
val url: String,
|
||||
val magnet: String?,
|
||||
val originalHash: String?,
|
||||
) : OrchestratorResult()
|
||||
}
|
||||
|
||||
suspend fun upload(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
compressionQuality: CompressorQuality,
|
||||
server: ServerName,
|
||||
account: Account,
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
val result =
|
||||
if (compressionQuality != CompressorQuality.UNCOMPRESSED) {
|
||||
updateState(0.02, UploadingState.Compressing)
|
||||
compressor.compress(uri, mimeType, compressionQuality, context.applicationContext)
|
||||
} else {
|
||||
MediaCompressorResult(uri, mimeType, null)
|
||||
}
|
||||
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(result.uri, result.contentType, context)
|
||||
ServerType.NIP96 -> uploadNIP96(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, account, context)
|
||||
ServerType.Blossom -> uploadBlossom(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, account, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,6 @@ import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.service.CachedCashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CashuToken
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.CopyIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
@@ -81,7 +82,7 @@ fun RenderLoaded(
|
||||
ZoomableContentView(
|
||||
content = MediaUrlImage(url, uri = callbackUri),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -90,7 +91,7 @@ fun RenderLoaded(
|
||||
ZoomableContentView(
|
||||
content = MediaUrlVideo(url, uri = callbackUri),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
val DefaultAnimationColors =
|
||||
listOf(
|
||||
Color(0xFF5851D8),
|
||||
Color(0xFF833AB4),
|
||||
Color(0xFFC13584),
|
||||
Color(0xFFE1306C),
|
||||
Color(0xFFFD1D1D),
|
||||
Color(0xFFF56040),
|
||||
Color(0xFFF77737),
|
||||
Color(0xFFFCAF45),
|
||||
Color(0xFFFFDC80),
|
||||
Color(0xFF5851D8),
|
||||
).toImmutableList()
|
||||
|
||||
@Composable
|
||||
fun LoadingAnimation(
|
||||
indicatorSize: Dp = 20.dp,
|
||||
circleColors: ImmutableList<Color> = DefaultAnimationColors,
|
||||
animationDuration: Int = 1000,
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
|
||||
val rotateAnimation by
|
||||
infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation =
|
||||
tween(
|
||||
durationMillis = animationDuration,
|
||||
easing = LinearEasing,
|
||||
),
|
||||
),
|
||||
label = "UploadGalleryUploadingAnimation",
|
||||
)
|
||||
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f },
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size = indicatorSize)
|
||||
.rotate(degrees = rotateAnimation)
|
||||
.border(
|
||||
width = 4.dp,
|
||||
brush = Brush.sweepGradient(circleColors),
|
||||
shape = CircleShape,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
strokeWidth = 1.dp,
|
||||
trackColor = ProgressIndicatorDefaults.circularDeterminateTrackColor,
|
||||
)
|
||||
}
|
||||
+100
-91
@@ -30,53 +30,56 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
|
||||
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
|
||||
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
|
||||
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
|
||||
import id.zelory.compressor.Compressor
|
||||
import id.zelory.compressor.constraint.default
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class MediaCompressorResult(
|
||||
val uri: Uri,
|
||||
val contentType: String?,
|
||||
val size: Long?,
|
||||
)
|
||||
|
||||
class MediaCompressor {
|
||||
// ALL ERRORS ARE IGNORED. The original file is returned.
|
||||
suspend fun compress(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
applicationContext: Context,
|
||||
onReady: (Uri, String?, Long?) -> Unit,
|
||||
onError: (Int) -> Unit,
|
||||
mediaQuality: CompressorQuality,
|
||||
) {
|
||||
applicationContext: Context,
|
||||
): MediaCompressorResult {
|
||||
// Skip compression if user selected uncompressed
|
||||
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
|
||||
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.")
|
||||
onReady(uri, contentType, null)
|
||||
return
|
||||
return MediaCompressorResult(uri, contentType, null)
|
||||
}
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
// branch into compression based on content type
|
||||
when {
|
||||
contentType?.startsWith("video", ignoreCase = true) == true ->
|
||||
compressVideo(uri, contentType, applicationContext, onReady, onError, mediaQuality)
|
||||
return when {
|
||||
contentType?.startsWith("video", ignoreCase = true) == true -> compressVideo(uri, contentType, applicationContext, mediaQuality)
|
||||
contentType?.startsWith("image", ignoreCase = true) == true &&
|
||||
!contentType.contains("gif") &&
|
||||
!contentType.contains("svg") ->
|
||||
compressImage(uri, contentType, applicationContext, onReady, onError, mediaQuality)
|
||||
else -> onReady(uri, contentType, null)
|
||||
compressImage(uri, contentType, applicationContext, mediaQuality)
|
||||
else -> MediaCompressorResult(uri, contentType, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun compressVideo(
|
||||
private suspend fun compressVideo(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
applicationContext: Context,
|
||||
onReady: (Uri, String?, Long?) -> Unit,
|
||||
onError: (Int) -> Unit,
|
||||
mediaQuality: CompressorQuality,
|
||||
) {
|
||||
): MediaCompressorResult {
|
||||
val videoQuality =
|
||||
when (mediaQuality) {
|
||||
CompressorQuality.VERY_LOW -> VideoQuality.VERY_LOW
|
||||
@@ -89,73 +92,77 @@ class MediaCompressor {
|
||||
|
||||
Log.d("MediaCompressor", "Using video compression $mediaQuality")
|
||||
|
||||
VideoCompressor.start(
|
||||
// => This is required
|
||||
context = applicationContext,
|
||||
// => Source can be provided as content uris
|
||||
uris = listOf(uri),
|
||||
isStreamable = false,
|
||||
// THIS STORAGE
|
||||
// sharedStorageConfiguration = SharedStorageConfiguration(
|
||||
// saveAt = SaveLocation.movies, // => default is movies
|
||||
// videoName = "compressed_video" // => required name
|
||||
// ),
|
||||
// OR AND NOT BOTH
|
||||
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
|
||||
configureWith =
|
||||
Configuration(
|
||||
quality = videoQuality,
|
||||
// => required name
|
||||
videoNames = listOf(UUID.randomUUID().toString()),
|
||||
),
|
||||
listener =
|
||||
object : CompressionListener {
|
||||
override fun onProgress(
|
||||
index: Int,
|
||||
percent: Float,
|
||||
) {
|
||||
}
|
||||
val result =
|
||||
withTimeoutOrNull(30000) {
|
||||
suspendCoroutine { continuation ->
|
||||
VideoCompressor.start(
|
||||
// => This is required
|
||||
context = applicationContext,
|
||||
// => Source can be provided as content uris
|
||||
uris = listOf(uri),
|
||||
isStreamable = false,
|
||||
// THIS STORAGE
|
||||
// sharedStorageConfiguration = SharedStorageConfiguration(
|
||||
// saveAt = SaveLocation.movies, // => default is movies
|
||||
// videoName = "compressed_video" // => required name
|
||||
// ),
|
||||
// OR AND NOT BOTH
|
||||
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
|
||||
configureWith =
|
||||
Configuration(
|
||||
quality = videoQuality,
|
||||
// => required name
|
||||
videoNames = listOf(UUID.randomUUID().toString()),
|
||||
),
|
||||
listener =
|
||||
object : CompressionListener {
|
||||
override fun onProgress(
|
||||
index: Int,
|
||||
percent: Float,
|
||||
) {}
|
||||
|
||||
override fun onStart(index: Int) {}
|
||||
override fun onStart(index: Int) {}
|
||||
|
||||
override fun onSuccess(
|
||||
index: Int,
|
||||
size: Long,
|
||||
path: String?,
|
||||
) {
|
||||
if (path != null) {
|
||||
Log.d("MediaCompressor", "Video compression success. Compressed size [$size]")
|
||||
onReady(Uri.fromFile(File(path)), contentType, size)
|
||||
} else {
|
||||
Log.d("MediaCompressor", "Video compression successful, but returned null path")
|
||||
onError(R.string.compression_returned_null)
|
||||
}
|
||||
}
|
||||
override fun onSuccess(
|
||||
index: Int,
|
||||
size: Long,
|
||||
path: String?,
|
||||
) {
|
||||
if (path != null) {
|
||||
Log.d("MediaCompressor", "Video compression success. Compressed size [$size]")
|
||||
continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size))
|
||||
} else {
|
||||
Log.d("MediaCompressor", "Video compression successful, but returned null path")
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
index: Int,
|
||||
failureMessage: String,
|
||||
) {
|
||||
Log.d("MediaCompressor", "Video compression failed: $failureMessage")
|
||||
// keeps going with original video
|
||||
onReady(uri, contentType, null)
|
||||
}
|
||||
override fun onFailure(
|
||||
index: Int,
|
||||
failureMessage: String,
|
||||
) {
|
||||
Log.d("MediaCompressor", "Video compression failed: $failureMessage")
|
||||
// keeps going with original video
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onCancelled(index: Int) {
|
||||
onError(R.string.compression_cancelled)
|
||||
}
|
||||
},
|
||||
)
|
||||
override fun onCancelled(index: Int) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result ?: MediaCompressorResult(uri, contentType, null)
|
||||
}
|
||||
|
||||
private suspend fun compressImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
context: Context,
|
||||
onReady: (Uri, String?, Long?) -> Unit,
|
||||
onError: (Int) -> Unit,
|
||||
mediaQuality: CompressorQuality,
|
||||
) {
|
||||
): MediaCompressorResult {
|
||||
val imageQuality =
|
||||
when (mediaQuality) {
|
||||
CompressorQuality.VERY_LOW -> 40
|
||||
@@ -166,7 +173,7 @@ class MediaCompressor {
|
||||
else -> 60
|
||||
}
|
||||
|
||||
try {
|
||||
return try {
|
||||
Log.d("MediaCompressor", "Using image compression $mediaQuality")
|
||||
val tempFile = MediaCompressorFileUtils.from(uri, context)
|
||||
val compressedImageFile =
|
||||
@@ -174,32 +181,34 @@ class MediaCompressor {
|
||||
default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality)
|
||||
}
|
||||
Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]")
|
||||
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
|
||||
MediaCompressorResult(compressedImageFile.toUri(), contentType, compressedImageFile.length())
|
||||
} catch (e: Exception) {
|
||||
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
onReady(uri, contentType, null)
|
||||
MediaCompressorResult(uri, contentType, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
|
||||
when (mediaQualityFloat) {
|
||||
0 -> CompressorQuality.LOW
|
||||
1 -> CompressorQuality.MEDIUM
|
||||
2 -> CompressorQuality.HIGH
|
||||
3 -> CompressorQuality.UNCOMPRESSED
|
||||
else -> CompressorQuality.MEDIUM
|
||||
}
|
||||
companion object {
|
||||
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
|
||||
when (mediaQualityFloat) {
|
||||
0 -> CompressorQuality.LOW
|
||||
1 -> CompressorQuality.MEDIUM
|
||||
2 -> CompressorQuality.HIGH
|
||||
3 -> CompressorQuality.UNCOMPRESSED
|
||||
else -> CompressorQuality.MEDIUM
|
||||
}
|
||||
|
||||
fun compressorQualityToInt(compressorQuality: CompressorQuality): Int =
|
||||
when (compressorQuality) {
|
||||
CompressorQuality.LOW -> 0
|
||||
CompressorQuality.MEDIUM -> 1
|
||||
CompressorQuality.HIGH -> 2
|
||||
CompressorQuality.UNCOMPRESSED -> 3
|
||||
else -> 1
|
||||
}
|
||||
fun compressorQualityToInt(compressorQuality: CompressorQuality): Int =
|
||||
when (compressorQuality) {
|
||||
CompressorQuality.LOW -> 0
|
||||
CompressorQuality.MEDIUM -> 1
|
||||
CompressorQuality.HIGH -> 2
|
||||
CompressorQuality.UNCOMPRESSED -> 3
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class CompressorQuality {
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.google.common.math.IntMath.sqrt
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import java.math.RoundingMode
|
||||
import kotlin.math.ceil
|
||||
|
||||
@Composable
|
||||
fun GridPreviewTemplate(count: Int) {
|
||||
AutoNonlazyGrid(count) { idx ->
|
||||
Box(Modifier.background(Color.Green).fillMaxSize())
|
||||
Text(idx.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items1() = GridPreviewTemplate(1)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items2() = GridPreviewTemplate(2)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items3() = GridPreviewTemplate(3)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items4() = GridPreviewTemplate(4)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items5() = GridPreviewTemplate(5)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items6() = GridPreviewTemplate(6)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items7() = GridPreviewTemplate(7)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items8() = GridPreviewTemplate(8)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items9() = GridPreviewTemplate(9)
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=300px,height=300px,dpi=440")
|
||||
fun Items10() = GridPreviewTemplate(10)
|
||||
|
||||
@Composable
|
||||
fun AutoNonlazyGrid(
|
||||
itemCount: Int,
|
||||
modifier: Modifier = Modifier.aspectRatio(1f),
|
||||
content: @Composable (Int) -> Unit,
|
||||
) {
|
||||
if (itemCount > 0) {
|
||||
NonlazyGrid(sqrt(itemCount, RoundingMode.UP), itemCount, modifier, content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NonlazyGrid(
|
||||
columns: Int,
|
||||
itemCount: Int,
|
||||
modifier: Modifier = Modifier.aspectRatio(1f),
|
||||
content: @Composable (Int) -> Unit,
|
||||
) {
|
||||
val skipItems = ((columns * columns) - itemCount)
|
||||
val skipInEveryRow = (skipItems / columns)
|
||||
val shouldSkip = skipItems % columns
|
||||
var addSkips = shouldSkip
|
||||
|
||||
val newColumns = columns - skipInEveryRow
|
||||
|
||||
Column(modifier = modifier, verticalArrangement = spacedBy(Size5dp)) {
|
||||
val rows = ceil(itemCount.toDouble() / newColumns).toInt()
|
||||
|
||||
for (rowId in 0 until rows) {
|
||||
val firstIndex = if (rowId == 0) 0 else (rowId * newColumns) - (shouldSkip - addSkips)
|
||||
|
||||
Row(Modifier.weight(1f), horizontalArrangement = spacedBy(Size5dp)) {
|
||||
val thisRowColumns =
|
||||
if (addSkips > 0) {
|
||||
addSkips--
|
||||
newColumns - 1
|
||||
} else {
|
||||
newColumns
|
||||
}
|
||||
|
||||
for (columnId in 0 until thisRowColumns) {
|
||||
val index = firstIndex + columnId
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
if (index < itemCount) {
|
||||
content(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFontFamilyResolver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
@@ -421,7 +422,7 @@ private fun ZoomableContentView(
|
||||
) {
|
||||
state.imagesForPager[word]?.let {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(it, state.imageList, roundedCorner = true, isFiniteHeight = false, accountViewModel)
|
||||
ZoomableContentView(it, state.imageList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -141,7 +141,7 @@ fun LoadThumbAndThenVideoView(
|
||||
thumbUri: String,
|
||||
authorName: String? = null,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
nostrUriCallback: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
@@ -173,7 +173,7 @@ fun LoadThumbAndThenVideoView(
|
||||
title = title,
|
||||
thumb = VideoThumb(loadingFinished.second),
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
@@ -187,7 +187,7 @@ fun LoadThumbAndThenVideoView(
|
||||
title = title,
|
||||
thumb = null,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
@@ -206,7 +206,7 @@ fun VideoView(
|
||||
thumb: VideoThumb? = null,
|
||||
roundedCorner: Boolean,
|
||||
gallery: Boolean = false,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
@@ -227,7 +227,7 @@ fun VideoView(
|
||||
Modifier
|
||||
}
|
||||
|
||||
VideoView(videoUri, mimeType, title, thumb, borderModifier, isFiniteHeight, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
|
||||
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -237,7 +237,7 @@ fun VideoView(
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
borderModifier: Modifier,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
@@ -281,7 +281,7 @@ fun VideoView(
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
borderModifier = borderModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
@@ -308,7 +308,7 @@ fun VideoView(
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
null,
|
||||
if (isFiniteHeight) ContentScale.FillWidth else ContentScale.FillWidth,
|
||||
contentScale,
|
||||
if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
|
||||
)
|
||||
|
||||
@@ -327,7 +327,7 @@ fun VideoView(
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
borderModifier = borderModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
@@ -352,7 +352,7 @@ fun VideoViewInner(
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
showControls: Boolean = true,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
borderModifier: Modifier,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
@@ -378,7 +378,7 @@ fun VideoViewInner(
|
||||
controller = controller,
|
||||
thumbData = thumb,
|
||||
showControls = showControls,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
waveform = waveform,
|
||||
keepPlaying = keepPlaying,
|
||||
@@ -729,7 +729,7 @@ private fun RenderVideoPlayer(
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
showControls: Boolean = true,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
nostrUriCallback: String?,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
@@ -761,10 +761,13 @@ private fun RenderVideoPlayer(
|
||||
hideController()
|
||||
|
||||
resizeMode =
|
||||
if (isFiniteHeight) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
when (contentScale) {
|
||||
ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
|
||||
if (showControls) {
|
||||
|
||||
+2
-2
@@ -410,7 +410,7 @@ private fun RenderImageOrVideo(
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
borderModifier = borderModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -457,7 +457,7 @@ private fun RenderImageOrVideo(
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
borderModifier = borderModifier,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+21
-17
@@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.service.Blurhash
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils
|
||||
import com.vitorpamplona.amethyst.ui.navigation.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
@@ -95,9 +94,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -110,7 +109,7 @@ fun ZoomableContentView(
|
||||
content: BaseMediaContent,
|
||||
images: ImmutableList<BaseMediaContent> = remember(content) { persistentListOf(content) },
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var dialogOpen by remember(content) { mutableStateOf(false) }
|
||||
@@ -122,13 +121,6 @@ fun ZoomableContentView(
|
||||
val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode)
|
||||
val isOrientationLocked = DeviceUtils.screenOrientationIsLocked(LocalContext.current)
|
||||
|
||||
val contentScale =
|
||||
if (isFiniteHeight) {
|
||||
ContentScale.Fit
|
||||
} else {
|
||||
ContentScale.FillWidth
|
||||
}
|
||||
|
||||
when (content) {
|
||||
is MediaUrlImage ->
|
||||
SensitivityWarning(content.contentWarning != null, accountViewModel) {
|
||||
@@ -154,7 +146,7 @@ fun ZoomableContentView(
|
||||
dimensions = content.dim,
|
||||
blurhash = content.blurhash,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = {
|
||||
dialogOpen = true
|
||||
@@ -186,7 +178,7 @@ fun ZoomableContentView(
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -355,11 +347,18 @@ fun UrlImageView(
|
||||
-> {
|
||||
if (content.blurhash != null) {
|
||||
if (ratio != null) {
|
||||
val modifier =
|
||||
if (contentScale == ContentScale.Crop) {
|
||||
loadedImageModifier.clickable { showImage.value = true }
|
||||
} else {
|
||||
loadedImageModifier.aspectRatio(ratio).clickable { showImage.value = true }
|
||||
}
|
||||
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
loadedImageModifier.aspectRatio(ratio),
|
||||
modifier,
|
||||
)
|
||||
} else {
|
||||
DisplayBlurHash(
|
||||
@@ -395,13 +394,18 @@ fun UrlImageView(
|
||||
}
|
||||
} else {
|
||||
if (content.blurhash != null && ratio != null) {
|
||||
val modifier =
|
||||
if (contentScale == ContentScale.Crop) {
|
||||
loadedImageModifier.clickable { showImage.value = true }
|
||||
} else {
|
||||
loadedImageModifier.aspectRatio(ratio).clickable { showImage.value = true }
|
||||
}
|
||||
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
loadedImageModifier
|
||||
.aspectRatio(ratio)
|
||||
.clickable { showImage.value = true },
|
||||
contentScale,
|
||||
modifier,
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
|
||||
+3
-2
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -95,7 +96,7 @@ class MarkdownMediaRenderer(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -117,7 +118,7 @@ class MarkdownMediaRenderer(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -46,6 +47,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
@@ -319,9 +321,9 @@ fun AcceptableNote(
|
||||
)
|
||||
}
|
||||
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, false, accountViewModel)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, false, accountViewModel)
|
||||
is VideoEvent -> JustVideoDisplay(baseNote, false, false, accountViewModel)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel)
|
||||
is VideoEvent -> JustVideoDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel)
|
||||
else ->
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
@@ -604,8 +606,8 @@ private fun RenderNoteRow(
|
||||
) {
|
||||
when (val noteEvent = baseNote.event) {
|
||||
is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav)
|
||||
is AudioTrackEvent -> RenderAudioTrack(baseNote, false, accountViewModel, nav)
|
||||
is AudioHeaderEvent -> RenderAudioHeader(baseNote, false, accountViewModel, nav)
|
||||
is AudioTrackEvent -> RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
|
||||
is AudioHeaderEvent -> RenderAudioHeader(baseNote, ContentScale.FillWidth, accountViewModel, nav)
|
||||
is DraftEvent -> RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
|
||||
is ReactionEvent -> RenderReaction(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
is RepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
@@ -731,12 +733,12 @@ private fun RenderNoteRow(
|
||||
nav,
|
||||
)
|
||||
}
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, false, accountViewModel)
|
||||
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
|
||||
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
|
||||
is PictureEvent -> PictureDisplay(baseNote, true, false, backgroundColor, accountViewModel, nav)
|
||||
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel)
|
||||
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
|
||||
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
|
||||
is PictureEvent -> PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav)
|
||||
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, false, accountViewModel)
|
||||
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel)
|
||||
is CommunityPostApprovalEvent -> {
|
||||
RenderPostApproval(
|
||||
baseNote,
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -61,20 +62,20 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun RenderAudioTrack(
|
||||
note: Note,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? AudioTrackEvent ?: return
|
||||
|
||||
AudioTrackHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav)
|
||||
AudioTrackHeader(noteEvent, note, contentScale, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AudioTrackHeader(
|
||||
noteEvent: AudioTrackEvent,
|
||||
note: Note,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -148,7 +149,7 @@ fun AudioTrackHeader(
|
||||
thumbUri = cover,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
nostrUriCallback = "nostr:${note.toNEvent()}",
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
@@ -159,7 +160,7 @@ fun AudioTrackHeader(
|
||||
title = noteEvent.subject(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -172,20 +173,20 @@ fun AudioTrackHeader(
|
||||
@Composable
|
||||
fun RenderAudioHeader(
|
||||
note: Note,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? AudioHeaderEvent ?: return
|
||||
|
||||
AudioHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav)
|
||||
AudioHeader(noteEvent, note, contentScale, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AudioHeader(
|
||||
noteEvent: AudioHeaderEvent,
|
||||
note: Note,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -210,7 +211,7 @@ fun AudioHeader(
|
||||
title = noteEvent.subject(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
nostrUriCallback = note.toNostrUri(),
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
@@ -38,7 +39,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
fun FileHeaderDisplay(
|
||||
note: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
@@ -84,7 +85,7 @@ fun FileHeaderDisplay(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
@@ -43,7 +44,7 @@ import java.io.File
|
||||
fun FileStorageHeaderDisplay(
|
||||
baseNote: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
|
||||
@@ -51,7 +52,7 @@ fun FileStorageHeaderDisplay(
|
||||
|
||||
LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
|
||||
if (contentNote != null) {
|
||||
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, isFiniteHeight, accountViewModel)
|
||||
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, contentScale, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +62,7 @@ private fun ObserverAndRenderNIP95(
|
||||
header: Note,
|
||||
content: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
|
||||
@@ -113,7 +114,7 @@ private fun ObserverAndRenderNIP95(
|
||||
ZoomableContentView(
|
||||
content = it,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -183,7 +184,7 @@ fun RenderLiveActivityEventInner(
|
||||
artworkUri = cover,
|
||||
authorName = baseNote.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
nostrUriCallback = "nostr:${baseNote.toNEvent()}",
|
||||
)
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -29,17 +31,18 @@ import androidx.compose.runtime.MutableState
|
||||
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.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.PictureEvent
|
||||
@@ -49,7 +52,8 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
fun PictureDisplay(
|
||||
note: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
padding: PaddingValues,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
@@ -85,7 +89,7 @@ fun PictureDisplay(
|
||||
Column {
|
||||
if (title != null) {
|
||||
Text(
|
||||
modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding,
|
||||
modifier = Modifier.padding(padding),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
@@ -95,19 +99,31 @@ fun PictureDisplay(
|
||||
Spacer(StdVertSpacer)
|
||||
}
|
||||
|
||||
ZoomableContentView(
|
||||
content = first,
|
||||
images = images,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
if (images.size == 1) {
|
||||
ZoomableContentView(
|
||||
content = images.first(),
|
||||
images = images,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
AutoNonlazyGrid(images.size) {
|
||||
ZoomableContentView(
|
||||
content = images[it],
|
||||
images = images,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = ContentScale.Crop,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = event.content,
|
||||
canPreview = false,
|
||||
quotesLeft = 0,
|
||||
modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding,
|
||||
modifier = Modifier.padding(padding),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
@@ -115,20 +131,6 @@ fun PictureDisplay(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
/*
|
||||
TranslatableRichTextViewer(
|
||||
content = ,
|
||||
modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding,
|
||||
|
||||
|
||||
|
||||
text = ,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ fun VideoDisplay(
|
||||
makeItShort: Boolean,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: MutableState<Color>,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -144,7 +144,7 @@ fun VideoDisplay(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
@@ -38,7 +39,7 @@ import com.vitorpamplona.quartz.events.VideoEvent
|
||||
fun JustVideoDisplay(
|
||||
note: Note,
|
||||
roundedCorner: Boolean,
|
||||
isFiniteHeight: Boolean,
|
||||
contentScale: ContentScale,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = (note.event as? VideoEvent) ?: return
|
||||
@@ -79,7 +80,7 @@ fun JustVideoDisplay(
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.ammolite.relays.BundledInsert
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
@@ -83,7 +84,6 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
|
||||
+47
-139
@@ -22,15 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
@@ -60,7 +54,6 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.filled.LocationOff
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
@@ -107,7 +100,6 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
@@ -136,20 +128,23 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.LocationState
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPollOption
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.getPhotoUri
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Nav
|
||||
@@ -186,8 +181,8 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -246,7 +241,8 @@ fun NewPostScreen(
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
}
|
||||
attachment?.let {
|
||||
postViewModel.selectImage(it)
|
||||
val mediaType = context.contentResolver.getType(it)
|
||||
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,7 +267,8 @@ fun NewPostScreen(
|
||||
}
|
||||
|
||||
(intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
||||
postViewModel.selectImage(it)
|
||||
val mediaType = context.contentResolver.getType(it)
|
||||
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,7 +455,7 @@ fun NewPostScreen(
|
||||
myUrlPreview,
|
||||
mimeType = null,
|
||||
roundedCorner = true,
|
||||
isFiniteHeight = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
@@ -509,22 +506,22 @@ fun NewPostScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val url = postViewModel.contentToAddUrl
|
||||
if (url != null) {
|
||||
if (postViewModel.mediaToUpload.isNotEmpty()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
postViewModel.mediaToUpload,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent, mediaQuality ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
|
||||
if (server.type != ServerType.NIP95) {
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server)
|
||||
}
|
||||
},
|
||||
onCancel = { postViewModel.contentToAddUrl = null },
|
||||
onDelete = postViewModel::deleteMediaToUpload,
|
||||
onCancel = { postViewModel.mediaToUpload = persistentListOf() },
|
||||
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
@@ -608,7 +605,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
|
||||
.height(50.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
UploadFromGallery(
|
||||
SelectFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
@@ -667,56 +664,6 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun TakePictureButton(onPictureTaken: (Uri) -> Unit) {
|
||||
var imageUri by remember { mutableStateOf<Uri?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture(),
|
||||
) { success ->
|
||||
if (success) {
|
||||
imageUri?.let {
|
||||
onPictureTaken(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val cameraPermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.CAMERA,
|
||||
onPermissionResult = {
|
||||
if (it) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
imageUri = getPhotoUri(context)
|
||||
imageUri?.let { uri -> launcher.launch(uri) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
imageUri = getPhotoUri(context)
|
||||
imageUri?.let { uri -> launcher.launch(uri) }
|
||||
}
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = stringRes(id = R.string.upload_image),
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollField(postViewModel: NewPostViewModel) {
|
||||
val optionsList = postViewModel.pollOptions
|
||||
@@ -1719,19 +1666,14 @@ fun CreateButton(
|
||||
|
||||
@Composable
|
||||
fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
uris: ImmutableList<SelectedMediaProcessing>,
|
||||
defaultServer: ServerName,
|
||||
onAdd: (String, ServerName, Boolean, Int) -> Unit,
|
||||
onDelete: (SelectedMediaProcessing) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onError: (Int) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
|
||||
val isImage = mediaType.startsWith("image")
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
|
||||
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
|
||||
|
||||
val fileServers by accountViewModel.account.liveServerList.collectAsState()
|
||||
@@ -1784,19 +1726,23 @@ fun ImageVideoDescription(
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
stringRes(
|
||||
if (isImage) {
|
||||
R.string.content_description_add_image
|
||||
val text =
|
||||
if (uris.size == 1) {
|
||||
if (uris.first().media.isImage() == true) {
|
||||
R.string.content_description_add_image
|
||||
} else {
|
||||
if (uris.first().media.isVideo() == true) {
|
||||
R.string.content_description_add_video
|
||||
} else {
|
||||
if (isVideo) {
|
||||
R.string.content_description_add_video
|
||||
} else {
|
||||
R.string.content_description_add_document
|
||||
}
|
||||
},
|
||||
),
|
||||
R.string.content_description_add_document
|
||||
}
|
||||
}
|
||||
} else {
|
||||
R.string.content_description_add_media
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringRes(text),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier =
|
||||
@@ -1827,48 +1773,7 @@ fun ImageVideoDescription(
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
) {
|
||||
if (mediaType.startsWith("image")) {
|
||||
AsyncImage(
|
||||
model = uri.toString(),
|
||||
contentDescription = uri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
)
|
||||
} else if (
|
||||
mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = uri) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(R.string.unable_to_load_thumbnail)
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, mimeType = mediaType, accountViewModel = accountViewModel)
|
||||
}
|
||||
ShowImageUploadGallery(uris, onDelete, accountViewModel)
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -2030,10 +1935,11 @@ fun SettingSwitchItem(
|
||||
onValueChange = onCheckedChange,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
modifier = Modifier.weight(2.0f),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(id = title),
|
||||
@@ -2049,10 +1955,12 @@ fun SettingSwitchItem(
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
enabled = enabled,
|
||||
)
|
||||
Column(Modifier.weight(1f), horizontalAlignment = Alignment.End) {
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-10
@@ -113,8 +113,8 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
@@ -341,7 +341,7 @@ fun ChannelScreen(
|
||||
}
|
||||
|
||||
// LAST ROW
|
||||
EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) {
|
||||
EditFieldRow(newPostModel, accountViewModel = accountViewModel) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
innerSendPost(replyTo, channel, newPostModel, accountViewModel, null)
|
||||
newPostModel.message = TextFieldValue("")
|
||||
@@ -395,7 +395,7 @@ private suspend fun innerSendPost(
|
||||
tagger.run()
|
||||
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
|
||||
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() }
|
||||
|
||||
if (channel is PublicChatChannel) {
|
||||
accountViewModel.account.sendChannelMessage(
|
||||
@@ -404,7 +404,7 @@ private suspend fun innerSendPost(
|
||||
replyTo = tagger.eTags,
|
||||
mentions = tagger.pTags,
|
||||
wantsToMarkAsSensitive = false,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = draftTag,
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
@@ -414,7 +414,7 @@ private suspend fun innerSendPost(
|
||||
replyTo = tagger.eTags,
|
||||
mentions = tagger.pTags,
|
||||
wantsToMarkAsSensitive = false,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = draftTag,
|
||||
)
|
||||
}
|
||||
@@ -469,7 +469,6 @@ fun DisplayReplyingToNote(
|
||||
@Composable
|
||||
fun EditFieldRow(
|
||||
channelScreenModel: NewPostViewModel,
|
||||
isPrivate: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onSendNewMessage: () -> Unit,
|
||||
) {
|
||||
@@ -506,17 +505,17 @@ fun EditFieldRow(
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
SelectFromGallery(
|
||||
isUploading = channelScreenModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = EditFieldLeadingIconModifier,
|
||||
) {
|
||||
channelScreenModel.selectImage(it)
|
||||
channelScreenModel.upload(
|
||||
galleryUri = it,
|
||||
alt = null,
|
||||
sensitiveContent = false,
|
||||
// Use MEDIUM quality
|
||||
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
|
||||
mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM),
|
||||
server = accountViewModel.account.settings.defaultFileServer,
|
||||
onError = accountViewModel::toast,
|
||||
context = context,
|
||||
@@ -793,7 +792,7 @@ fun ShowVideoStreaming(
|
||||
ZoomableContentView(
|
||||
content = zoomableUrlVideo,
|
||||
roundedCorner = false,
|
||||
isFiniteHeight = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
+9
-10
@@ -87,8 +87,8 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
@@ -482,7 +482,7 @@ fun ChatroomScreen(
|
||||
}
|
||||
|
||||
// LAST ROW
|
||||
PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) {
|
||||
PrivateMessageEditFieldRow(newPostModel, accountViewModel) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
innerSendPost(newPostModel, room, replyTo, accountViewModel, null)
|
||||
|
||||
@@ -505,7 +505,7 @@ private fun innerSendPost(
|
||||
dTag: String?,
|
||||
) {
|
||||
val urls = findURLs(newPostModel.message.text)
|
||||
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
|
||||
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() }
|
||||
|
||||
if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) {
|
||||
accountViewModel.account.sendNIP17PrivateMessage(
|
||||
@@ -514,7 +514,7 @@ private fun innerSendPost(
|
||||
replyingTo = replyTo.value,
|
||||
mentions = null,
|
||||
wantsToMarkAsSensitive = false,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = dTag,
|
||||
)
|
||||
} else {
|
||||
@@ -524,7 +524,7 @@ private fun innerSendPost(
|
||||
replyingTo = replyTo.value,
|
||||
mentions = null,
|
||||
wantsToMarkAsSensitive = false,
|
||||
nip94attachments = usedAttachments,
|
||||
imetas = usedAttachments,
|
||||
draftTag = dTag,
|
||||
)
|
||||
}
|
||||
@@ -533,7 +533,6 @@ private fun innerSendPost(
|
||||
@Composable
|
||||
fun PrivateMessageEditFieldRow(
|
||||
channelScreenModel: NewPostViewModel,
|
||||
isPrivate: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onSendNewMessage: () -> Unit,
|
||||
) {
|
||||
@@ -573,7 +572,7 @@ fun PrivateMessageEditFieldRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 6.dp),
|
||||
) {
|
||||
UploadFromGallery(
|
||||
SelectFromGallery(
|
||||
isUploading = channelScreenModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier =
|
||||
@@ -581,13 +580,13 @@ fun PrivateMessageEditFieldRow(
|
||||
.size(30.dp)
|
||||
.padding(start = 2.dp),
|
||||
) {
|
||||
channelScreenModel.selectImage(it)
|
||||
channelScreenModel.upload(
|
||||
galleryUri = it,
|
||||
alt = null,
|
||||
sensitiveContent = false,
|
||||
// use MEDIUM quality
|
||||
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
|
||||
isPrivate = isPrivate,
|
||||
mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM),
|
||||
isPrivate = true,
|
||||
server = accountViewModel.account.settings.defaultFileServer,
|
||||
onError = accountViewModel::toast,
|
||||
context = context,
|
||||
|
||||
+8
-6
@@ -25,6 +25,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -156,6 +157,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
@@ -512,19 +514,19 @@ private fun FullBleedNoteCompose(
|
||||
nav = nav,
|
||||
)
|
||||
} else if (noteEvent is VideoEvent) {
|
||||
VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, isFiniteHeight = false, accountViewModel = accountViewModel, nav = nav)
|
||||
VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, ContentScale.FillWidth, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is PictureEvent) {
|
||||
PictureDisplay(baseNote, roundedCorner = true, isFiniteHeight = false, backgroundColor, accountViewModel = accountViewModel, nav)
|
||||
PictureDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, PaddingValues(vertical = Size5dp), backgroundColor, accountViewModel = accountViewModel, nav)
|
||||
} else if (noteEvent is FileHeaderEvent) {
|
||||
FileHeaderDisplay(baseNote, roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel)
|
||||
FileHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel)
|
||||
} else if (noteEvent is FileStorageHeaderEvent) {
|
||||
FileStorageHeaderDisplay(baseNote, roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel)
|
||||
FileStorageHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel)
|
||||
} else if (noteEvent is PeopleListEvent) {
|
||||
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is AudioTrackEvent) {
|
||||
AudioTrackHeader(noteEvent, baseNote, false, accountViewModel, nav)
|
||||
AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav)
|
||||
} else if (noteEvent is AudioHeaderEvent) {
|
||||
AudioHeader(noteEvent, baseNote, false, accountViewModel, nav)
|
||||
AudioHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav)
|
||||
} else if (noteEvent is CommunityPostApprovalEvent) {
|
||||
RenderPostApproval(
|
||||
baseNote,
|
||||
|
||||
+91
-181
@@ -20,18 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.video
|
||||
|
||||
import android.Manifest
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -41,64 +34,50 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.getPhotoUri
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun NewImageButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
navScrollToTop: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
|
||||
var wantsToPostFromGallery by remember { mutableStateOf(false) }
|
||||
|
||||
var wantsToPostFromCamera by remember { mutableStateOf(false) }
|
||||
|
||||
var cameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
var pickedURI by remember { mutableStateOf<Uri?>(null) }
|
||||
var pickedURIs by remember { mutableStateOf<ImmutableList<SelectedMedia>>(persistentListOf()) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -111,180 +90,111 @@ fun NewImageButton(
|
||||
}
|
||||
|
||||
if (wantsToPostFromCamera) {
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture(),
|
||||
) { success ->
|
||||
if (success) {
|
||||
cameraUri?.let {
|
||||
pickedURI = it
|
||||
}
|
||||
}
|
||||
cameraUri = null
|
||||
wantsToPostFromCamera = false
|
||||
}
|
||||
|
||||
val cameraPermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.CAMERA,
|
||||
onPermissionResult = {
|
||||
if (it) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
cameraUri = getPhotoUri(context)
|
||||
cameraUri?.let { launcher.launch(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
LaunchedEffect(key1 = accountViewModel) {
|
||||
launch(Dispatchers.IO) {
|
||||
cameraUri = getPhotoUri(context)
|
||||
cameraUri?.let { launcher.launch(it) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() }
|
||||
TakePicture { uri ->
|
||||
wantsToPostFromCamera = false
|
||||
pickedURIs = uri
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsToPostFromGallery) {
|
||||
var showGallerySelect by remember { mutableStateOf(false) }
|
||||
if (showGallerySelect) {
|
||||
GallerySelect(
|
||||
onImageUri = { uri ->
|
||||
wantsToPostFromGallery = false
|
||||
showGallerySelect = false
|
||||
pickedURI = uri
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
showGallerySelect = true
|
||||
GallerySelect(
|
||||
onImageUri = { uri ->
|
||||
wantsToPostFromGallery = false
|
||||
pickedURIs = uri
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pickedURI?.let {
|
||||
if (pickedURIs.isNotEmpty()) {
|
||||
NewMediaView(
|
||||
uri = it,
|
||||
onClose = { pickedURI = null },
|
||||
uris = pickedURIs,
|
||||
onClose = { pickedURIs = persistentListOf() },
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.isUploadingImage) {
|
||||
ShowProgress(postViewModel)
|
||||
} else {
|
||||
Column {
|
||||
// if (isOpen) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = isOpen,
|
||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||
) {
|
||||
Column {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
wantsToPostFromCamera = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = stringRes(id = R.string.upload_image),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
wantsToPostFromGallery = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddPhotoAlternate,
|
||||
contentDescription = stringRes(id = R.string.upload_image),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
isOpen = !isOpen
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isOpen,
|
||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Column {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
wantsToPostFromCamera = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraAlt,
|
||||
contentDescription = stringRes(id = R.string.upload_image),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
wantsToPostFromGallery = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddPhotoAlternate,
|
||||
contentDescription = stringRes(id = R.string.upload_image),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = stringRes(id = R.string.new_short),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
isOpen = !isOpen
|
||||
},
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
AnimatedVisibility(
|
||||
visible = !isOpen,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isOpen,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = stringRes(id = R.string.new_short),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !isOpen,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
contentDescription = stringRes(id = R.string.new_short),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
contentDescription = stringRes(id = R.string.new_short),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowProgress(postViewModel: NewMediaModel) {
|
||||
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
progress =
|
||||
animateFloatAsState(
|
||||
targetValue = postViewModel.uploadingPercentage.value,
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
).value,
|
||||
modifier =
|
||||
Size55Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
strokeWidth = 5.dp,
|
||||
)
|
||||
postViewModel.uploadingDescription.value?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -24,6 +24,7 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
@@ -46,6 +47,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -295,13 +297,13 @@ private fun RenderVideoOrPictureNote(
|
||||
if (noteEvent is PictureEvent) {
|
||||
val backgroundColor = remember { mutableStateOf(Color.Transparent) }
|
||||
|
||||
PictureDisplay(note, false, true, backgroundColor, accountViewModel, nav)
|
||||
PictureDisplay(note, false, ContentScale.Fit, PaddingValues(5.dp), backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is FileHeaderEvent) {
|
||||
FileHeaderDisplay(note, false, true, accountViewModel)
|
||||
FileHeaderDisplay(note, false, ContentScale.Fit, accountViewModel)
|
||||
} else if (noteEvent is FileStorageHeaderEvent) {
|
||||
FileStorageHeaderDisplay(note, false, true, accountViewModel)
|
||||
FileStorageHeaderDisplay(note, false, ContentScale.Fit, accountViewModel)
|
||||
} else if (noteEvent is VideoEvent) {
|
||||
JustVideoDisplay(note, false, true, accountViewModel)
|
||||
JustVideoDisplay(note, false, ContentScale.Fit, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.service.PackageUtils
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
|
||||
@@ -364,11 +364,15 @@
|
||||
<string name="hash_verification_passed">This content is the same since the post</string>
|
||||
<string name="hash_verification_failed">This content has changed. The author might not have seen or approved the change</string>
|
||||
|
||||
<string name="content_description_add_media">Add Media</string>
|
||||
<string name="content_description_add_image">Add Image</string>
|
||||
<string name="content_description_add_video">Add Video</string>
|
||||
<string name="content_description_add_document">Add Document</string>
|
||||
|
||||
<string name="add_content">Add to Message</string>
|
||||
<string name="add_caption">Add a Caption</string>
|
||||
<string name="add_caption_example">My lovely friend</string>
|
||||
|
||||
<string name="content_description">Description of the contents</string>
|
||||
<string name="content_description_example">A blue boat in a white sandy beach at sunset</string>
|
||||
|
||||
@@ -389,6 +393,8 @@
|
||||
|
||||
|
||||
<string name="file_server">File Server</string>
|
||||
<string name="file_server_description">Choose a server to upload this file to</string>
|
||||
|
||||
<string name="zap_forward_lnAddress">LnAddress or @User</string>
|
||||
|
||||
|
||||
@@ -404,6 +410,14 @@
|
||||
<string name="add_media_server">Add media server</string>
|
||||
<string name="delete_media_server">Delete media server</string>
|
||||
|
||||
<string name="uploading_state_ready">Not Started</string>
|
||||
<string name="uploading_state_compressing">Compressing</string>
|
||||
<string name="uploading_state_uploading">Uploading</string>
|
||||
<string name="uploading_state_server_processing">Processing</string>
|
||||
<string name="uploading_state_downloading">Downloading</string>
|
||||
<string name="uploading_state_hashing">Hashing</string>
|
||||
<string name="uploading_state_finished">Done</string>
|
||||
<string name="uploading_state_error">Error</string>
|
||||
|
||||
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
|
||||
@@ -866,6 +880,7 @@
|
||||
<string name="failed_to_upload_media">Uploading error: %1$s</string>
|
||||
<string name="server_did_not_provide_a_url_after_uploading">Server did not provide a URL after uploading</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_prepare_local_file_to_upload">Could not prepare local file 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>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -136,7 +137,7 @@ class Nip96Test {
|
||||
|
||||
@Test()
|
||||
fun parseNostrBuild() {
|
||||
val info = Nip96Retriever().parse("https://nostr.build", json)
|
||||
val info = ServerInfoRetriever().parse("https://nostr.build", json)
|
||||
|
||||
assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl)
|
||||
assertEquals("https://media.nostr.build", info.downloadUrl)
|
||||
@@ -165,7 +166,7 @@ class Nip96Test {
|
||||
|
||||
@Test()
|
||||
fun parseRelativeUrls() {
|
||||
val info = Nip96Retriever().parse("https://test.com", relativeUrlTest)
|
||||
val info = ServerInfoRetriever().parse("https://test.com", relativeUrlTest)
|
||||
|
||||
assertEquals("https://test.com/n96", info.apiUrl)
|
||||
assertEquals("https://test.com/", info.downloadUrl)
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.benchmark
|
||||
import androidx.benchmark.junit4.BenchmarkRule
|
||||
import androidx.benchmark.junit4.measureRepeated
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
+32
@@ -20,7 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.preview
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoderOld
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
@@ -71,4 +77,30 @@ class BlurhashTest {
|
||||
|
||||
assertTrue(bmp1!!.sameAs(bmp2!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBlack() {
|
||||
assertEquals("U00000fQfQfQfQfQfQfQfQfQfQfQfQfQfQfQ", load("/black.png").toBlurhash())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test1x1() {
|
||||
assertEquals("U~TSUA~q~q~q~q~q~q~q~q~q~q~q~q~q~q~q", load("/1x1.png").toBlurhash())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWhite() {
|
||||
assertEquals("U2TSUA~qfQ~q~qj[fQj[fQfQfQfQ~qj[fQj[", load("/white.png").toBlurhash())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLorikeet() {
|
||||
println("${load("/lorikeet.jpg").toBlurhash()}")
|
||||
assertEquals("rFDcT@_LNs#p%Mt*nNM}E2VrIVX6VuV@WUo{xtjv9]RRw[OXS}rrWFX9w{OZxaxWNHX4n\$M}NGaK%0RkM}w{xto|jFs,Sh-Tj]bcwJnjXSxZs.NI", load("/lorikeet.jpg").toBlurhash())
|
||||
}
|
||||
|
||||
private fun load(filename: String): Bitmap =
|
||||
javaClass.getResourceAsStream(filename).use { inputStream ->
|
||||
BitmapFactory.decodeStream(inputStream)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 B |
Binary file not shown.
|
After Width: | Height: | Size: 233 B |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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.commons.blurhash
|
||||
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.log
|
||||
|
||||
object Base83 {
|
||||
fun encode(value: Long): String =
|
||||
if (value > 82) {
|
||||
encode(value, ceil(log((value + 1).toDouble(), 83.0)).toInt())
|
||||
} else {
|
||||
encode(value, 1)
|
||||
}
|
||||
|
||||
fun encode(
|
||||
value: Long,
|
||||
length: Int,
|
||||
): String {
|
||||
val buffer = CharArray(length)
|
||||
encode(value, length, buffer, 0)
|
||||
return String(buffer)
|
||||
}
|
||||
|
||||
fun encode(
|
||||
value: Long,
|
||||
length: Int,
|
||||
buffer: CharArray,
|
||||
offset: Int,
|
||||
) {
|
||||
var exp = 1L
|
||||
for (i in 1..length) {
|
||||
val digit = (value / exp % 83).toInt()
|
||||
buffer[offset + length - i] = ALPHABET[digit]
|
||||
exp *= 83
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeAt(
|
||||
str: String,
|
||||
at: Int = 0,
|
||||
): Int = charMap[str[at].code]
|
||||
|
||||
fun decodeFixed2(
|
||||
str: String,
|
||||
from: Int = 0,
|
||||
): Int = charMap[str[from].code] * 83 + charMap[str[from + 1].code]
|
||||
|
||||
fun decode(
|
||||
str: String,
|
||||
from: Int = 0,
|
||||
to: Int = str.length,
|
||||
): Int {
|
||||
var result = 0
|
||||
for (i in from until to) {
|
||||
result = result * 83 + charMap[str[i].code]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val ALPHABET: CharArray = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".toCharArray()
|
||||
|
||||
private val charMap =
|
||||
ALPHABET
|
||||
.mapIndexed { i, c -> c.code to i }
|
||||
.toMap()
|
||||
.let { charMap ->
|
||||
Array(255) {
|
||||
charMap[it] ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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.commons.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun Bitmap.toBlurhash(): String {
|
||||
val aspectRatio = this.width.toFloat() / this.height.toFloat()
|
||||
|
||||
if (this.width > 100 && this.height > 100) {
|
||||
return Bitmap.createScaledBitmap(this, 100, (100 / aspectRatio).toInt(), false).toBlurhash()
|
||||
}
|
||||
|
||||
val intArray = IntArray(width * height)
|
||||
this.getPixels(intArray, 0, width, 0, 0, width, height)
|
||||
|
||||
val numX =
|
||||
if (aspectRatio > 1) {
|
||||
9
|
||||
} else if (aspectRatio < 1) {
|
||||
(9 * aspectRatio).roundToInt()
|
||||
} else {
|
||||
4
|
||||
}
|
||||
|
||||
val numY =
|
||||
if (aspectRatio > 1) {
|
||||
(9 * (1 / aspectRatio)).roundToInt()
|
||||
} else if (aspectRatio < 1) {
|
||||
9
|
||||
} else {
|
||||
4
|
||||
}
|
||||
|
||||
return BlurHashEncoder().encode(
|
||||
intArray,
|
||||
width,
|
||||
height,
|
||||
numX,
|
||||
numY,
|
||||
)
|
||||
}
|
||||
+12
-200
@@ -18,38 +18,22 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.preview
|
||||
package com.vitorpamplona.amethyst.commons.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import kotlin.math.cos
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.srgbToLinear
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.withSign
|
||||
|
||||
object BlurHashDecoder {
|
||||
// cache Math.cos() calculations to improve performance.
|
||||
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY *
|
||||
// 2 * nBitmaps
|
||||
// the cache is enabled by default, it is recommended to disable it only when just a few images
|
||||
// are displayed
|
||||
private val cacheCosinesX = HashMap<Int, DoubleArray>()
|
||||
private val cacheCosinesY = HashMap<Int, DoubleArray>()
|
||||
|
||||
/**
|
||||
* Clear calculations stored in memory cache. The cache is not big, but will increase when many
|
||||
* image sizes are used, if the app needs memory it is recommended to clear it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
cacheCosinesX.clear()
|
||||
cacheCosinesY.clear()
|
||||
}
|
||||
|
||||
/** Returns width/height */
|
||||
fun aspectRatio(blurHash: String?): Float? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83At(blurHash, 0)
|
||||
val numCompEnc = Base83.decodeAt(blurHash, 0)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
@@ -64,18 +48,18 @@ object BlurHashDecoder {
|
||||
numCompY: Int,
|
||||
blurHash: String,
|
||||
): Array<FloatArray> {
|
||||
val maxAc = (decode83At(blurHash, 1) + 1) / 166f
|
||||
val maxAc = (Base83.decodeAt(blurHash, 1) + 1) / 166f
|
||||
return Array(numCompX * numCompY) { i ->
|
||||
if (i == 0) {
|
||||
decodeDc(decode83(blurHash, 2, 6))
|
||||
decodeDc(Base83.decode(blurHash, 2, 6))
|
||||
} else {
|
||||
decodeAc(decode83Fixed2(blurHash, 4 + i * 2), maxAc)
|
||||
decodeAc(Base83.decodeFixed2(blurHash, 4 + i * 2), maxAc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun computeNumComponets(blurHash: String): Pair<Int, Int> {
|
||||
val numCompEnc = decode83At(blurHash, 0)
|
||||
val numCompEnc = Base83.decodeAt(blurHash, 0)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
return Pair(numCompX, numCompY)
|
||||
@@ -106,28 +90,6 @@ object BlurHashDecoder {
|
||||
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun decode83At(
|
||||
str: String,
|
||||
at: Int = 0,
|
||||
): Int = charMap[str[at].code]
|
||||
|
||||
private fun decode83Fixed2(
|
||||
str: String,
|
||||
from: Int = 0,
|
||||
): Int = charMap[str[from].code] * 83 + charMap[str[from + 1].code]
|
||||
|
||||
private fun decode83(
|
||||
str: String,
|
||||
from: Int = 0,
|
||||
to: Int = str.length,
|
||||
): Int {
|
||||
var result = 0
|
||||
for (i in from until to) {
|
||||
result = result * 83 + charMap[str[i].code]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||
val r = colorEnc shr 16
|
||||
val g = (colorEnc shr 8) and 255
|
||||
@@ -135,15 +97,6 @@ object BlurHashDecoder {
|
||||
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||
}
|
||||
|
||||
private fun srgbToLinear(colorEnc: Int): Float {
|
||||
val v = colorEnc / 255f
|
||||
return if (v <= 0.04045f) {
|
||||
(v / 12.92f)
|
||||
} else {
|
||||
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAc(
|
||||
value: Int,
|
||||
maxAc: Float,
|
||||
@@ -170,10 +123,10 @@ object BlurHashDecoder {
|
||||
): IntArray {
|
||||
// use an array for better performance when writing pixel colors
|
||||
val imageArray = IntArray(width * height)
|
||||
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
|
||||
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
|
||||
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||
val calculateCosX = !useCache || !CosineCache.hasX(width * numCompX)
|
||||
val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||
val calculateCosY = !useCache || !CosineCache.hasY(height * numCompY)
|
||||
val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||
|
||||
var r = 0.0f
|
||||
var g = 0.0f
|
||||
@@ -208,145 +161,4 @@ object BlurHashDecoder {
|
||||
green: Int,
|
||||
blue: Int,
|
||||
): Int = -0x1000000 or (red shl 16) or (green shl 8) or blue
|
||||
|
||||
private fun getArrayForCosinesY(
|
||||
calculate: Boolean,
|
||||
height: Int,
|
||||
numCompY: Int,
|
||||
) = when {
|
||||
calculate -> {
|
||||
DoubleArray(height * numCompY) {
|
||||
val y = it / numCompY
|
||||
val j = it % numCompY
|
||||
cos(Math.PI * y * j / height)
|
||||
}.also {
|
||||
cacheCosinesY[height * numCompY] = it
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
cacheCosinesY[height * numCompY]!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesX(
|
||||
calculate: Boolean,
|
||||
width: Int,
|
||||
numCompX: Int,
|
||||
) = when {
|
||||
calculate -> {
|
||||
DoubleArray(width * numCompX) {
|
||||
val x = it / numCompX
|
||||
val i = it % numCompX
|
||||
cos(Math.PI * x * i / width)
|
||||
}.also { cacheCosinesX[width * numCompX] = it }
|
||||
}
|
||||
else -> cacheCosinesX[width * numCompX]!!
|
||||
}
|
||||
|
||||
private fun linearToSrgb(value: Float): Int {
|
||||
val v = value.coerceIn(0f, 1f)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private val linToSrgbApproximation =
|
||||
Array(255) {
|
||||
linearToSrgb(it / 255f)
|
||||
}
|
||||
|
||||
private val charMap =
|
||||
listOf(
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'*',
|
||||
'+',
|
||||
',',
|
||||
'-',
|
||||
'.',
|
||||
':',
|
||||
';',
|
||||
'=',
|
||||
'?',
|
||||
'@',
|
||||
'[',
|
||||
']',
|
||||
'^',
|
||||
'_',
|
||||
'{',
|
||||
'|',
|
||||
'}',
|
||||
'~',
|
||||
).mapIndexed { i, c -> c.code to i }
|
||||
.toMap()
|
||||
.let { charMap ->
|
||||
Array(255) {
|
||||
charMap[it] ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.preview
|
||||
package com.vitorpamplona.amethyst.commons.blurhash
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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.commons.blurhash
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.Base83.encode
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.withSign
|
||||
|
||||
class BlurHashEncoder {
|
||||
fun signPow(
|
||||
value: Double,
|
||||
exp: Double,
|
||||
): Double = abs(value).pow(exp).withSign(value)
|
||||
|
||||
private fun encodeAC(
|
||||
value: DoubleArray,
|
||||
maximumValue: Double,
|
||||
): Long {
|
||||
val quantR = floor(max(0.0, min(18.0, floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5))))
|
||||
val quantG = floor(max(0.0, min(18.0, floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5))))
|
||||
val quantB = floor(max(0.0, min(18.0, floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5))))
|
||||
return Math.round(quantR * 19 * 19 + quantG * 19 + quantB)
|
||||
}
|
||||
|
||||
private fun encodeDC(value: DoubleArray): Long {
|
||||
val r = linearToSrgb(value[0]).toLong()
|
||||
val g = linearToSrgb(value[1]).toLong()
|
||||
val b = linearToSrgb(value[2]).toLong()
|
||||
return (r shl 16) + (g shl 8) + b
|
||||
}
|
||||
|
||||
fun max(
|
||||
values: Array<DoubleArray>,
|
||||
from: Int,
|
||||
endExclusive: Int,
|
||||
): Double {
|
||||
var result = Double.NEGATIVE_INFINITY
|
||||
for (i in from until endExclusive) {
|
||||
for (j in values[i].indices) {
|
||||
val value = values[i][j]
|
||||
if (value > result) {
|
||||
result = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun encode(
|
||||
pixels: IntArray,
|
||||
width: Int,
|
||||
height: Int,
|
||||
componentX: Int,
|
||||
componentY: Int,
|
||||
useCache: Boolean = true,
|
||||
): String {
|
||||
require(!(componentX < 1 || componentX > 9 || componentY < 1 || componentY > 9)) { "Blur hash must have between 1 and 9 components" }
|
||||
require(width * height == pixels.size) { "Width and height must match the pixels array" }
|
||||
|
||||
val factors = Array(componentX * componentY) { DoubleArray(3) }
|
||||
|
||||
val calculateCosX = !useCache || !CosineCache.hasX(width * componentX)
|
||||
val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, componentX)
|
||||
val calculateCosY = !useCache || !CosineCache.hasY(height * componentY)
|
||||
val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, componentY)
|
||||
|
||||
val scale = 1.0 / (width * height)
|
||||
|
||||
var r = 0.0
|
||||
var g = 0.0
|
||||
var b = 0.0
|
||||
|
||||
for (j in 0 until componentY) {
|
||||
for (i in 0 until componentX) {
|
||||
val normalisation = (if (i == 0 && j == 0) 1 else 2).toDouble()
|
||||
|
||||
r = 0.0
|
||||
g = 0.0
|
||||
b = 0.0
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
val basis = normalisation * cosinesY[j + componentY * y] * cosinesX[i + componentX * x]
|
||||
val pixel = pixels[y * width + x]
|
||||
r += basis * SRGB.srgbToLinear((pixel shr 16) and 0xff)
|
||||
g += basis * SRGB.srgbToLinear((pixel shr 8) and 0xff)
|
||||
b += basis * SRGB.srgbToLinear(pixel and 0xff)
|
||||
}
|
||||
}
|
||||
|
||||
val colors = factors[j * componentX + i]
|
||||
colors[0] = r * scale
|
||||
colors[1] = g * scale
|
||||
colors[2] = b * scale
|
||||
}
|
||||
}
|
||||
|
||||
val hash = CharArray(1 + 1 + 4 + 2 * (factors.size - 1)) // size flag + max AC + DC + 2 * AC components
|
||||
val sizeFlag = (componentX - 1 + (componentY - 1) * 9).toLong()
|
||||
encode(sizeFlag, 1, hash, 0)
|
||||
|
||||
val maximumValue: Double
|
||||
if (factors.size > 1) {
|
||||
val actualMaximumValue = max(factors, 1, factors.size)
|
||||
val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5))))
|
||||
maximumValue = (quantisedMaximumValue + 1) / 166
|
||||
encode(Math.round(quantisedMaximumValue), 1, hash, 1)
|
||||
} else {
|
||||
maximumValue = 1.0
|
||||
encode(0, 1, hash, 1)
|
||||
}
|
||||
|
||||
val dc = factors[0]
|
||||
encode(encodeDC(dc), 4, hash, 2)
|
||||
|
||||
for (i in 1 until factors.size) {
|
||||
encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1))
|
||||
}
|
||||
return String(hash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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.commons.blurhash
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import kotlin.math.cos
|
||||
|
||||
object CosineCache {
|
||||
// cache Math.cos() calculations to improve performance.
|
||||
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY *
|
||||
// 2 * nBitmaps
|
||||
// the cache is enabled by default, it is recommended to disable it only when just a few images
|
||||
// are displayed
|
||||
private val cacheCosinesX = LruCache<Int, DoubleArray>(20)
|
||||
private val cacheCosinesY = LruCache<Int, DoubleArray>(20)
|
||||
|
||||
/**
|
||||
* Clear calculations stored in memory cache. The cache is not big, but will increase when many
|
||||
* image sizes are used, if the app needs memory it is recommended to clear it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
cacheCosinesX.evictAll()
|
||||
cacheCosinesY.evictAll()
|
||||
}
|
||||
|
||||
fun hasX(idx: Int) = cacheCosinesX.get(idx) != null
|
||||
|
||||
fun hasY(idx: Int) = cacheCosinesY.get(idx) != null
|
||||
|
||||
fun getArrayForCosinesY(
|
||||
calculate: Boolean,
|
||||
height: Int,
|
||||
numCompY: Int,
|
||||
) = when {
|
||||
calculate -> {
|
||||
DoubleArray(height * numCompY) {
|
||||
val y = it / numCompY
|
||||
val j = it % numCompY
|
||||
cos(Math.PI * y * j / height)
|
||||
}.also {
|
||||
cacheCosinesY.put(height * numCompY, it)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
cacheCosinesY[height * numCompY]!!
|
||||
}
|
||||
}
|
||||
|
||||
fun getArrayForCosinesX(
|
||||
calculate: Boolean,
|
||||
width: Int,
|
||||
numCompX: Int,
|
||||
) = when {
|
||||
calculate -> {
|
||||
DoubleArray(width * numCompX) {
|
||||
val x = it / numCompX
|
||||
val i = it % numCompX
|
||||
cos(Math.PI * x * i / width)
|
||||
}.also { cacheCosinesX.put(width * numCompX, it) }
|
||||
}
|
||||
else -> cacheCosinesX[width * numCompX]!!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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.commons.blurhash
|
||||
|
||||
import kotlin.math.pow
|
||||
|
||||
class SRGB {
|
||||
companion object {
|
||||
fun linearToSrgb(value: Float): Int {
|
||||
val v = value.coerceIn(0.0f, 1.0f)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun linearToSrgb(value: Double): Int {
|
||||
val v = value.coerceIn(0.0, 1.0)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun srgbToLinear(value: Int): Float {
|
||||
val valueCheck = value.coerceIn(0, 255)
|
||||
|
||||
val v = valueCheck / 255f
|
||||
return if (v <= 0.04045f) {
|
||||
v / 12.92f
|
||||
} else {
|
||||
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import java.io.File
|
||||
|
||||
@Immutable
|
||||
|
||||
@@ -24,10 +24,10 @@ import android.util.Log
|
||||
import android.util.Patterns
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
|
||||
import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.events.Dimension
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -50,7 +50,7 @@ class RichTextParser {
|
||||
callbackUri: String? = null,
|
||||
): MediaUrlContent? {
|
||||
val frags = Nip54InlineMetadata().parse(fullUrl)
|
||||
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
|
||||
val tags = Nip92MediaAttachments.parse(fullUrl, eventTags.lists)
|
||||
|
||||
val contentType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE]
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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.commons
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.Base83
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class Base83Test {
|
||||
@Test
|
||||
fun testEncodeDecode() {
|
||||
for (i in 0..820000) {
|
||||
assertEquals("$i encode decode", i, Base83.decode(Base83.encode(i.toLong())))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSingleDigits() {
|
||||
for (i in 0..82) {
|
||||
val expected: String = String(Base83.ALPHABET, i, 1)
|
||||
assertEquals("$i encodes", expected, Base83.encode(i.toLong(), 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test0000() {
|
||||
assertEquals("0000", Base83.encode(0, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test0001() {
|
||||
assertEquals("0001", Base83.encode(1, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test0010() {
|
||||
assertEquals("0010", Base83.encode(83, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test0011() {
|
||||
assertEquals("0011", Base83.encode(83 + 1, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test00X0() {
|
||||
assertEquals("00~0", Base83.encode(83 * 82, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test0100() {
|
||||
assertEquals("0100", Base83.encode(83 * 83, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test00XXEncode() {
|
||||
assertEquals("00~~", Base83.encode(83 * 82 + 82, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test0XXXDecode() {
|
||||
assertEquals(82 + 82 * 83 + 82 * 83 * 83, Base83.decode("0~~~"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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.commons
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.SRGB
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import kotlin.math.round
|
||||
|
||||
class SRGBTest {
|
||||
@Test
|
||||
fun testEncodeDecode() {
|
||||
for (i in 0..255) {
|
||||
assertEquals("$i encode decode", i, SRGB.linearToSrgb(SRGB.srgbToLinear(i)))
|
||||
}
|
||||
|
||||
for (i in 0..100) {
|
||||
val srgb = SRGB.linearToSrgb(i / 100.0f)
|
||||
val linear = round(SRGB.srgbToLinear(srgb) * 100).toInt()
|
||||
|
||||
assertEquals("$i decode encode", i, linear)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ audiowaveform = "1.1.1"
|
||||
benchmark = "1.3.3"
|
||||
benchmarkJunit4 = "1.3.3"
|
||||
biometricKtx = "1.2.0-alpha05"
|
||||
blurhash = "1.0.0"
|
||||
coil = "3.0.4"
|
||||
composeBom = "2024.12.01"
|
||||
coreKtx = "1.15.0"
|
||||
@@ -123,7 +122,6 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
|
||||
rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" }
|
||||
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
|
||||
tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
|
||||
trbl-blurhash = { group = "io.trbl", name = "blurhash", version.ref = "blurhash" }
|
||||
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
||||
url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" }
|
||||
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts" }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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.encoders
|
||||
|
||||
class Dimension(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
) {
|
||||
fun aspectRatio() = width.toFloat() / height.toFloat()
|
||||
|
||||
fun hasSize() = width > 0 && height > 0
|
||||
|
||||
override fun toString() = "${width}x$height"
|
||||
|
||||
companion object {
|
||||
fun parse(dim: String): Dimension? {
|
||||
if (dim == "0x0") return null
|
||||
|
||||
val parts = dim.split("x")
|
||||
if (parts.size != 2) return null
|
||||
|
||||
return try {
|
||||
val width = parts[0].toInt()
|
||||
val height = parts[1].toInt()
|
||||
|
||||
Dimension(width, height)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,43 +20,36 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.encoders
|
||||
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class Nip54InlineMetadata {
|
||||
fun convertFromFileHeader(header: FileHeaderEvent): String? {
|
||||
val myUrl = header.url() ?: return null
|
||||
return createUrl(
|
||||
myUrl,
|
||||
header.tags,
|
||||
fun createUrl(header: IMetaTag): String =
|
||||
createUrl(
|
||||
header.url,
|
||||
header.properties,
|
||||
)
|
||||
}
|
||||
|
||||
fun createUrl(
|
||||
imageUrl: String,
|
||||
tags: Array<Array<String>>,
|
||||
url: String,
|
||||
tags: Map<String, String>,
|
||||
): String {
|
||||
val extension =
|
||||
tags
|
||||
.mapNotNull {
|
||||
if (it.isNotEmpty() && it[0] != "url") {
|
||||
if (it.size > 1) {
|
||||
"${it[0]}=${URLEncoder.encode(it[1], "utf-8")}"
|
||||
} else {
|
||||
"${it[0]}}="
|
||||
}
|
||||
if (it.key != "url") {
|
||||
"${it.key}=${URLEncoder.encode(it.value, "utf-8")}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.joinToString("&")
|
||||
|
||||
return if (imageUrl.contains("#")) {
|
||||
"$imageUrl&$extension"
|
||||
return if (url.contains("#")) {
|
||||
"$url&$extension"
|
||||
} else {
|
||||
"$imageUrl#$extension"
|
||||
"$url#$extension"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,65 +20,108 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.encoders
|
||||
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ALT
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.BLUR_HASH
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.DIMENSION
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.FILE_SIZE
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.HASH
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MAGNET_URI
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MIME_TYPE
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ORIGINAL_HASH
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.TORRENT_INFOHASH
|
||||
|
||||
class IMetaTag(
|
||||
val url: String,
|
||||
val properties: Map<String, String>,
|
||||
)
|
||||
|
||||
class IMetaTagBuilder(
|
||||
val url: String,
|
||||
) {
|
||||
val properties = mutableMapOf<String, String>()
|
||||
|
||||
fun add(
|
||||
key: String,
|
||||
value: String,
|
||||
): IMetaTagBuilder {
|
||||
properties.set(key, value)
|
||||
return this
|
||||
}
|
||||
|
||||
fun magnet(uri: String) = add(MAGNET_URI, uri)
|
||||
|
||||
fun mimeType(mime: String) = add(MIME_TYPE, mime)
|
||||
|
||||
fun alt(alt: String) = add(ALT, alt)
|
||||
|
||||
fun hash(hash: HexKey) = add(HASH, hash)
|
||||
|
||||
fun size(size: Int) = add(FILE_SIZE, size.toString())
|
||||
|
||||
fun dims(dims: Dimension) = add(DIMENSION, dims.toString())
|
||||
|
||||
fun blurhash(blurhash: String) = add(BLUR_HASH, blurhash)
|
||||
|
||||
fun originalHash(originalHash: String) = add(ORIGINAL_HASH, originalHash)
|
||||
|
||||
fun torrent(uri: String) = add(TORRENT_INFOHASH, uri)
|
||||
|
||||
fun sensitiveContent(reason: String) = add("content-warning", reason)
|
||||
|
||||
fun build() = IMetaTag(url, properties)
|
||||
}
|
||||
|
||||
class Nip92MediaAttachments {
|
||||
companion object {
|
||||
const val IMETA = "imeta"
|
||||
}
|
||||
|
||||
fun convertFromFileHeader(header: FileHeaderEvent): Array<String>? {
|
||||
val myUrl = header.url() ?: return null
|
||||
return createTag(
|
||||
myUrl,
|
||||
header.tags,
|
||||
)
|
||||
}
|
||||
fun createTag(header: IMetaTag): Array<String> =
|
||||
createTag(
|
||||
header.url,
|
||||
header.properties,
|
||||
)
|
||||
|
||||
fun createTag(
|
||||
imageUrl: String,
|
||||
tags: Array<Array<String>>,
|
||||
): Array<String> =
|
||||
arrayOf(
|
||||
IMETA,
|
||||
"url $imageUrl",
|
||||
) +
|
||||
tags.mapNotNull {
|
||||
if (it.isNotEmpty() && it[0] != "url") {
|
||||
if (it.size > 1) {
|
||||
"${it[0]} ${it[1]}"
|
||||
fun createTag(
|
||||
url: String,
|
||||
tags: Map<String, String>,
|
||||
): Array<String> =
|
||||
arrayOf(
|
||||
IMETA,
|
||||
"url $url",
|
||||
) +
|
||||
tags.mapNotNull {
|
||||
if (it.key != "url") {
|
||||
"${it.key} ${it.value}"
|
||||
} else {
|
||||
"${it[0]}}"
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun parse(
|
||||
url: String,
|
||||
tags: Array<Array<String>>,
|
||||
): Map<String, String> =
|
||||
tags
|
||||
.firstOrNull {
|
||||
it.size > 1 && it[0] == IMETA && it[1] == "url $url"
|
||||
}?.let { tagList ->
|
||||
parseIMeta(tagList)
|
||||
} ?: emptyMap()
|
||||
|
||||
fun parse(tags: Array<Array<String>>): Map<String, Map<String, String>> =
|
||||
tags.filter { it.size > 1 && it[0] == IMETA }.associate {
|
||||
val allTags = parseIMeta(it)
|
||||
(allTags.get("url") ?: "") to allTags
|
||||
}
|
||||
|
||||
private fun parseIMeta(tags: Array<String>): Map<String, String> =
|
||||
tags.associate { tag ->
|
||||
val parts = tag.split(" ", limit = 2)
|
||||
when (parts.size) {
|
||||
2 -> parts[0] to parts[1]
|
||||
1 -> parts[0] to ""
|
||||
else -> "" to ""
|
||||
}
|
||||
}
|
||||
|
||||
fun parse(
|
||||
imageUrl: String,
|
||||
tags: Array<Array<String>>,
|
||||
): Map<String, String> =
|
||||
tags
|
||||
.firstOrNull {
|
||||
it.size > 1 && it[0] == IMETA && it[1] == "url $imageUrl"
|
||||
}?.let { tagList ->
|
||||
parseIMeta(tagList)
|
||||
} ?: emptyMap()
|
||||
|
||||
fun parse(tags: Array<Array<String>>): Map<String, Map<String, String>> =
|
||||
tags.filter { it.size > 1 && it[0] == IMETA }.associate {
|
||||
val allTags = parseIMeta(it)
|
||||
(allTags.get("url") ?: "") to allTags
|
||||
}
|
||||
|
||||
fun parseIMeta(tags: Array<String>): Map<String, String> =
|
||||
tags.associate { tag ->
|
||||
val parts = tag.split(" ", limit = 2)
|
||||
when (parts.size) {
|
||||
2 -> parts[0] to parts[1]
|
||||
1 -> parts[0] to ""
|
||||
else -> "" to ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -62,7 +63,7 @@ class ChannelMessageEvent(
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
isDraft: Boolean,
|
||||
onReady: (ChannelMessageEvent) -> Unit,
|
||||
) {
|
||||
@@ -80,12 +81,8 @@ class ChannelMessageEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
tags.add(
|
||||
arrayOf("alt", ALT),
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -79,7 +80,7 @@ class ChatMessageEvent(
|
||||
geohash: String? = null,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
isDraft: Boolean,
|
||||
onReady: (ChatMessageEvent) -> Unit,
|
||||
) {
|
||||
@@ -96,12 +97,8 @@ class ChatMessageEvent(
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
subject?.let { tags.add(arrayOf("subject", it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
// tags.add(arrayOf("alt", alt))
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.events
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -112,7 +114,7 @@ class ClassifiedsEvent(
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
isDraft: Boolean,
|
||||
@@ -188,10 +190,8 @@ class ClassifiedsEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
// tags.add(arrayOf("nip94", it.toJson()))
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
tags.add(arrayOf("alt", ALT))
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.ETag
|
||||
import com.vitorpamplona.quartz.encoders.EventHint
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.encoders.PTag
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
@@ -96,7 +97,7 @@ class CommentEvent(
|
||||
usersMentioned: Set<PTag> = emptySet(),
|
||||
addressesMentioned: Set<ATag> = emptySet(),
|
||||
eventsMentioned: Set<ETag> = emptySet(),
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
geohash: String? = null,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
markAsSensitive: Boolean = false,
|
||||
@@ -119,7 +120,7 @@ class CommentEvent(
|
||||
tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey))
|
||||
tags.add(arrayOf("k", "${replyingTo.event.kind}"))
|
||||
|
||||
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
|
||||
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
|
||||
}
|
||||
|
||||
fun replyComment(
|
||||
@@ -128,7 +129,7 @@ class CommentEvent(
|
||||
usersMentioned: Set<PTag> = emptySet(),
|
||||
addressesMentioned: Set<ATag> = emptySet(),
|
||||
eventsMentioned: Set<ETag> = emptySet(),
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
geohash: String? = null,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
markAsSensitive: Boolean = false,
|
||||
@@ -146,7 +147,7 @@ class CommentEvent(
|
||||
tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey))
|
||||
tags.add(arrayOf("k", "${replyingTo.event.kind}"))
|
||||
|
||||
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
|
||||
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
|
||||
}
|
||||
|
||||
fun createGeoComment(
|
||||
@@ -155,7 +156,7 @@ class CommentEvent(
|
||||
usersMentioned: Set<PTag> = emptySet(),
|
||||
addressesMentioned: Set<ATag> = emptySet(),
|
||||
eventsMentioned: Set<ETag> = emptySet(),
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
markAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
@@ -168,7 +169,7 @@ class CommentEvent(
|
||||
geohash?.let { tags.addAll(rootGeohashMipMap(it)) }
|
||||
tags.add(arrayOf("K", "geo"))
|
||||
|
||||
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, null, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
|
||||
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, null, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
|
||||
}
|
||||
|
||||
private fun create(
|
||||
@@ -177,7 +178,7 @@ class CommentEvent(
|
||||
usersMentioned: Set<PTag> = emptySet(),
|
||||
addressesMentioned: Set<ATag> = emptySet(),
|
||||
eventsMentioned: Set<ETag> = emptySet(),
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
geohash: String? = null,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
markAsSensitive: Boolean = false,
|
||||
@@ -209,12 +210,8 @@ class CommentEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -74,6 +75,41 @@ class FileHeaderEvent(
|
||||
const val ORIGINAL_HASH = "ox"
|
||||
const val ALT = "alt"
|
||||
|
||||
fun buildTags(
|
||||
url: String,
|
||||
magnetUri: String? = null,
|
||||
mimeType: String? = null,
|
||||
alt: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
originalHash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
): Array<Array<String>> =
|
||||
listOfNotNull(
|
||||
arrayOf(URL, url),
|
||||
magnetUri?.let { arrayOf(MAGNET_URI, it) },
|
||||
mimeType?.let { arrayOf(MIME_TYPE, it) },
|
||||
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
|
||||
hash?.let { arrayOf(HASH, it) },
|
||||
size?.let { arrayOf(FILE_SIZE, it) },
|
||||
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
|
||||
blurhash?.let { arrayOf(BLUR_HASH, it) },
|
||||
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
|
||||
magnetURI?.let { arrayOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
|
||||
sensitiveContent?.let {
|
||||
if (it) {
|
||||
arrayOf("content-warning", "")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
).toTypedArray()
|
||||
|
||||
fun create(
|
||||
url: String,
|
||||
magnetUri: String? = null,
|
||||
@@ -91,30 +127,10 @@ class FileHeaderEvent(
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
onReady: (FileHeaderEvent) -> Unit,
|
||||
) {
|
||||
val tags =
|
||||
listOfNotNull(
|
||||
arrayOf(URL, url),
|
||||
magnetUri?.let { arrayOf(MAGNET_URI, it) },
|
||||
mimeType?.let { arrayOf(MIME_TYPE, it) },
|
||||
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
|
||||
hash?.let { arrayOf(HASH, it) },
|
||||
size?.let { arrayOf(FILE_SIZE, it) },
|
||||
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
|
||||
blurhash?.let { arrayOf(BLUR_HASH, it) },
|
||||
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
|
||||
magnetURI?.let { arrayOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
|
||||
sensitiveContent?.let {
|
||||
if (it) {
|
||||
arrayOf("content-warning", "")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
val tags = buildTags(url, magnetUri, mimeType, alt, hash, size, dimensions, blurhash, originalHash, magnetURI, torrentInfoHash, sensitiveContent)
|
||||
|
||||
val content = alt ?: ""
|
||||
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
|
||||
signer.sign(createdAt, KIND, tags, content, onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -90,7 +91,7 @@ class GitReplyEvent(
|
||||
root: String? = null,
|
||||
directMentions: Set<HexKey> = emptySet(),
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
forkedFrom: Event? = null,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
@@ -148,12 +149,8 @@ class GitReplyEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
tags.add(arrayOf("alt", "a git issue reply"))
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
|
||||
open class InteractiveStoryBaseEvent(
|
||||
@@ -51,7 +52,7 @@ open class InteractiveStoryBaseEvent(
|
||||
markAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
): Array<Array<String>> {
|
||||
val tags = mutableListOf<Array<String>>()
|
||||
findHashtags(content).forEach {
|
||||
@@ -71,12 +72,8 @@ open class InteractiveStoryBaseEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
return tags.toTypedArray()
|
||||
}
|
||||
|
||||
+3
-2
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -59,7 +60,7 @@ class InteractiveStoryPrologueEvent(
|
||||
markAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
isDraft: Boolean,
|
||||
@@ -67,7 +68,7 @@ class InteractiveStoryPrologueEvent(
|
||||
) {
|
||||
val tags =
|
||||
makeTags(baseId, ALT + title, title, summary, image, options) +
|
||||
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, nip94attachments)
|
||||
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, imetas)
|
||||
|
||||
if (isDraft) {
|
||||
signer.assembleRumor(createdAt, KIND, tags, content, onReady)
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -57,7 +58,7 @@ class InteractiveStorySceneEvent(
|
||||
markAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
isDraft: Boolean,
|
||||
@@ -65,7 +66,7 @@ class InteractiveStorySceneEvent(
|
||||
) {
|
||||
val tags =
|
||||
makeTags(baseId, ALT + title, title, options = options) +
|
||||
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, nip94attachments)
|
||||
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, imetas)
|
||||
|
||||
if (isDraft) {
|
||||
signer.assembleRumor(createdAt, KIND, tags, content, onReady)
|
||||
|
||||
+4
-7
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -73,7 +74,7 @@ class LiveActivitiesChatMessageEvent(
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
isDraft: Boolean,
|
||||
onReady: (LiveActivitiesChatMessageEvent) -> Unit,
|
||||
) {
|
||||
@@ -92,12 +93,8 @@ class LiveActivitiesChatMessageEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
tags.add(arrayOf("alt", ALT))
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.events
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
|
||||
class NIP17Factory {
|
||||
@@ -79,7 +80,7 @@ class NIP17Factory {
|
||||
markAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
draftTag: String? = null,
|
||||
onReady: (Result) -> Unit,
|
||||
) {
|
||||
@@ -97,7 +98,7 @@ class NIP17Factory {
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
isDraft = draftTag != null,
|
||||
nip94attachments = nip94attachments,
|
||||
imetas = imetas,
|
||||
) { senderMessage ->
|
||||
if (draftTag != null) {
|
||||
onReady(
|
||||
|
||||
@@ -22,13 +22,13 @@ package com.vitorpamplona.quartz.events
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.ETag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA
|
||||
import com.vitorpamplona.quartz.encoders.PTag
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Immutable
|
||||
class PictureEvent(
|
||||
@@ -334,36 +334,6 @@ class PictureMeta(
|
||||
}
|
||||
}
|
||||
|
||||
class Dimension(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
) {
|
||||
fun aspectRatio() = width.toFloat() / height.toFloat()
|
||||
|
||||
fun hasSize() = width > 0 && height > 0
|
||||
|
||||
override fun toString() = "${width}x$height"
|
||||
|
||||
companion object {
|
||||
fun parse(dim: String): Dimension? {
|
||||
if (dim == "0x0") return null
|
||||
|
||||
val parts = dim.split("x")
|
||||
if (parts.size != 2) return null
|
||||
|
||||
return try {
|
||||
val width = parts[0].toInt()
|
||||
val height = parts[1].toInt()
|
||||
|
||||
Dimension(width, height)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UserAnnotation(
|
||||
val pubkey: HexKey,
|
||||
val x: Int,
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -79,7 +80,7 @@ class PollNoteEvent(
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
isDraft: Boolean,
|
||||
onReady: (PollNoteEvent) -> Unit,
|
||||
) {
|
||||
@@ -104,12 +105,8 @@ class PollNoteEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
tags.add(arrayOf("alt", ALT))
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.HexValidator
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -124,16 +125,13 @@ class PrivateDmEvent(
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
isDraft: Boolean,
|
||||
onReady: (PrivateDmEvent) -> Unit,
|
||||
) {
|
||||
var message = msg
|
||||
nip94attachments?.forEach {
|
||||
val myUrl = it.url()
|
||||
if (myUrl != null) {
|
||||
message = message.replace(myUrl, Nip54InlineMetadata().createUrl(myUrl, it.tags))
|
||||
}
|
||||
imetas?.forEach {
|
||||
message = message.replace(it.url, Nip54InlineMetadata().createUrl(it.url, it.properties))
|
||||
}
|
||||
|
||||
message =
|
||||
@@ -156,13 +154,10 @@ class PrivateDmEvent(
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
/* Privacy issue: DO NOT ADD THESE TO THE TAGS.
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
*/
|
||||
|
||||
tags.add(arrayOf("alt", ALT))
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -56,7 +57,7 @@ class TextNoteEvent(
|
||||
root: String? = null,
|
||||
directMentions: Set<HexKey> = emptySet(),
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
forkedFrom: Event? = null,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
@@ -122,12 +123,8 @@ class TextNoteEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -61,7 +62,7 @@ class TorrentCommentEvent(
|
||||
directMentions: Set<HexKey> = emptySet(),
|
||||
zapRaiserAmount: Long?,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
imetas: List<IMetaTag>? = null,
|
||||
forkedFrom: Event? = null,
|
||||
isDraft: Boolean,
|
||||
onReady: (TorrentCommentEvent) -> Unit,
|
||||
@@ -122,12 +123,8 @@ class TorrentCommentEvent(
|
||||
}
|
||||
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
|
||||
geohash?.let { tags.addAll(geohashMipMap(it)) }
|
||||
nip94attachments?.let {
|
||||
it.forEach {
|
||||
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
|
||||
tags.add(it)
|
||||
}
|
||||
}
|
||||
imetas?.forEach {
|
||||
tags.add(Nip92MediaAttachments.createTag(it))
|
||||
}
|
||||
tags.add(arrayOf("alt", ALT))
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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.Nip92MediaAttachments.Companion.IMETA
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
Reference in New Issue
Block a user