Merge emoji image uploader into integration branch
This commit is contained in:
+27
-5
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
@@ -41,7 +42,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -54,14 +59,16 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
* flag: when `true`, the caller is expected to store the entry in the event's
|
||||
* encrypted `.content` (NIP-51 private tags) rather than as a public tag.
|
||||
*
|
||||
* NOTE: Private emojis are currently only visible to the pack owner when viewing
|
||||
* their own pack here. They are NOT surfaced in the reaction menu or in the `:`
|
||||
* autocomplete picker, because those consumers read public tags only via
|
||||
* [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePack].
|
||||
* The dialog surfaces a warning so users understand the tradeoff.
|
||||
* Private emojis are visible only to the pack owner, but they ARE surfaced in
|
||||
* both the reaction menu and the `:` autocomplete picker once the app decrypts
|
||||
* them. Decryption is asynchronous; autocomplete shows the public list first
|
||||
* and the private entries are appended once decryption finishes. See
|
||||
* [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePackWithPrivate].
|
||||
*/
|
||||
@Composable
|
||||
fun AddEmojiDialog(
|
||||
viewModel: EmojiPackViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (EmojiUrlTag, Boolean) -> Unit,
|
||||
) {
|
||||
@@ -113,6 +120,21 @@ fun AddEmojiDialog(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text(stringRes(R.string.emoji_url_label)) },
|
||||
leadingIcon = {
|
||||
val context = LocalContext.current
|
||||
SelectSingleFromGallery(
|
||||
isUploading = viewModel.isUploadingEmojiImage,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
) { selected ->
|
||||
viewModel.uploadEmojiImage(
|
||||
uri = selected,
|
||||
context = context,
|
||||
onUploaded = { uploadedUrl -> url = uploadedUrl },
|
||||
onError = accountViewModel.toastManager::toast,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(DoubleVertSpacer)
|
||||
OutlinedTextField(
|
||||
|
||||
+2
@@ -168,6 +168,8 @@ private fun EmojiPackScreenView(
|
||||
|
||||
if (showAddDialog) {
|
||||
AddEmojiDialog(
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = { showAddDialog = false },
|
||||
onConfirm = { tag, isPrivate ->
|
||||
accountViewModel.launchSigner {
|
||||
|
||||
+117
@@ -20,14 +20,32 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
|
||||
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.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
class EmojiPackViewModel(
|
||||
@@ -39,6 +57,8 @@ class EmojiPackViewModel(
|
||||
.getOwnedEmojiPackFlow(packIdentifier)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null)
|
||||
|
||||
var isUploadingEmojiImage by mutableStateOf(false)
|
||||
|
||||
suspend fun addEmoji(
|
||||
emoji: EmojiUrlTag,
|
||||
isPrivate: Boolean,
|
||||
@@ -57,6 +77,103 @@ class EmojiPackViewModel(
|
||||
account.deleteOwnedEmojiPack(packIdentifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an image selected from the gallery to the account's default file
|
||||
* server (NIP-96 or Blossom) and calls [onUploaded] with the resulting URL.
|
||||
*
|
||||
* Mirrors the uploader pattern used by
|
||||
* `BookmarkGroupMetadataViewModel.uploadForPicture` — see that file for the
|
||||
* canonical implementation.
|
||||
*/
|
||||
fun uploadEmojiImage(
|
||||
uri: SelectedMedia,
|
||||
context: Context,
|
||||
onUploaded: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
upload(
|
||||
uri,
|
||||
context,
|
||||
onUploading = { isUploadingEmojiImage = it },
|
||||
onUploaded = onUploaded,
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upload(
|
||||
galleryUri: SelectedMedia,
|
||||
context: Context,
|
||||
onUploading: (Boolean) -> Unit,
|
||||
onUploaded: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
val sourceUri =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
if (!result.stripped) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
onUploading(false)
|
||||
return
|
||||
}
|
||||
result.uri
|
||||
} else {
|
||||
galleryUri.uri
|
||||
}
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
try {
|
||||
val result =
|
||||
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
|
||||
Nip96Uploader().upload(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
onProgress = {},
|
||||
httpAuth = account::createHTTPAuthorization,
|
||||
context = context,
|
||||
)
|
||||
} else {
|
||||
BlossomUploader().upload(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
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 (_: SignerExceptions.ReadOnlyException) {
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class Initializer(
|
||||
val account: Account,
|
||||
|
||||
+17
-2
@@ -37,11 +37,13 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
@@ -104,7 +106,7 @@ private fun EmojiPackMetadataScaffold(
|
||||
PackName(viewModel)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
PackImage(viewModel)
|
||||
PackImage(viewModel, accountViewModel)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
PackDescription(viewModel)
|
||||
@@ -184,7 +186,10 @@ private fun PackName(viewModel: EmojiPackMetadataViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PackImage(viewModel: EmojiPackMetadataViewModel) {
|
||||
private fun PackImage(
|
||||
viewModel: EmojiPackMetadataViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.emoji_pack_image_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -196,6 +201,16 @@ private fun PackImage(viewModel: EmojiPackMetadataViewModel) {
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
val context = LocalContext.current
|
||||
SelectSingleFromGallery(
|
||||
isUploading = viewModel.isUploadingImageForPicture,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
) {
|
||||
viewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+106
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -27,9 +28,24 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
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.model.Account
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
class EmojiPackMetadataViewModel : ViewModel() {
|
||||
@@ -43,6 +59,8 @@ class EmojiPackMetadataViewModel : ViewModel() {
|
||||
val picture = mutableStateOf(TextFieldValue())
|
||||
val description = mutableStateOf(TextFieldValue())
|
||||
|
||||
var isUploadingImageForPicture by mutableStateOf(false)
|
||||
|
||||
val canPost by derivedStateOf {
|
||||
name.value.text.isNotBlank()
|
||||
}
|
||||
@@ -91,4 +109,92 @@ class EmojiPackMetadataViewModel : ViewModel() {
|
||||
picture.value = TextFieldValue()
|
||||
description.value = TextFieldValue()
|
||||
}
|
||||
|
||||
fun uploadForPicture(
|
||||
uri: SelectedMedia,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
upload(
|
||||
uri,
|
||||
context,
|
||||
onUploading = { isUploadingImageForPicture = it },
|
||||
onUploaded = { picture.value = TextFieldValue(it) },
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upload(
|
||||
galleryUri: SelectedMedia,
|
||||
context: Context,
|
||||
onUploading: (Boolean) -> Unit,
|
||||
onUploaded: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
val sourceUri =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
if (!result.stripped) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
onUploading(false)
|
||||
return
|
||||
}
|
||||
result.uri
|
||||
} else {
|
||||
galleryUri.uri
|
||||
}
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
try {
|
||||
val result =
|
||||
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
|
||||
Nip96Uploader().upload(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
onProgress = {},
|
||||
httpAuth = account::createHTTPAuthorization,
|
||||
context = context,
|
||||
)
|
||||
} else {
|
||||
BlossomUploader().upload(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
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 (_: SignerExceptions.ReadOnlyException) {
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user