feat(emoji): upload emoji image inline in AddEmojiDialog
Adds a gallery-picker icon as the leadingIcon of the URL field in AddEmojiDialog. The uploader uses the same NIP-96/Blossom pathway as BookmarkGroupMetadataViewModel — the upload function and progress state live on EmojiPackViewModel so the dialog stays composable-only. On upload success the returned URL is written back to the dialog's local url state, leaving the user's shortcode entry and private toggle intact, so the existing validation flow (EmojiUrlTag.isValidShortcode + supportingText error) keeps working. https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn
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.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.LockOpen
|
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.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.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.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
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
|
* 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.
|
* 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
|
* Private emojis are visible only to the pack owner, but they ARE surfaced in
|
||||||
* their own pack here. They are NOT surfaced in the reaction menu or in the `:`
|
* both the reaction menu and the `:` autocomplete picker once the app decrypts
|
||||||
* autocomplete picker, because those consumers read public tags only via
|
* them. Decryption is asynchronous; autocomplete shows the public list first
|
||||||
* [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePack].
|
* and the private entries are appended once decryption finishes. See
|
||||||
* The dialog surfaces a warning so users understand the tradeoff.
|
* [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePackWithPrivate].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AddEmojiDialog(
|
fun AddEmojiDialog(
|
||||||
|
viewModel: EmojiPackViewModel,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onConfirm: (EmojiUrlTag, Boolean) -> Unit,
|
onConfirm: (EmojiUrlTag, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -113,6 +120,21 @@ fun AddEmojiDialog(
|
|||||||
value = url,
|
value = url,
|
||||||
onValueChange = { url = it },
|
onValueChange = { url = it },
|
||||||
label = { Text(stringRes(R.string.emoji_url_label)) },
|
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)
|
Spacer(DoubleVertSpacer)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|||||||
+2
@@ -168,6 +168,8 @@ private fun EmojiPackScreenView(
|
|||||||
|
|
||||||
if (showAddDialog) {
|
if (showAddDialog) {
|
||||||
AddEmojiDialog(
|
AddEmojiDialog(
|
||||||
|
viewModel = viewModel,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
onDismiss = { showAddDialog = false },
|
onDismiss = { showAddDialog = false },
|
||||||
onConfirm = { tag, isPrivate ->
|
onConfirm = { tag, isPrivate ->
|
||||||
accountViewModel.launchSigner {
|
accountViewModel.launchSigner {
|
||||||
|
|||||||
+117
@@ -20,14 +20,32 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Stable
|
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.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
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.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 com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class EmojiPackViewModel(
|
class EmojiPackViewModel(
|
||||||
@@ -39,6 +57,8 @@ class EmojiPackViewModel(
|
|||||||
.getOwnedEmojiPackFlow(packIdentifier)
|
.getOwnedEmojiPackFlow(packIdentifier)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null)
|
||||||
|
|
||||||
|
var isUploadingEmojiImage by mutableStateOf(false)
|
||||||
|
|
||||||
suspend fun addEmoji(
|
suspend fun addEmoji(
|
||||||
emoji: EmojiUrlTag,
|
emoji: EmojiUrlTag,
|
||||||
isPrivate: Boolean,
|
isPrivate: Boolean,
|
||||||
@@ -57,6 +77,103 @@ class EmojiPackViewModel(
|
|||||||
account.deleteOwnedEmojiPack(packIdentifier)
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
class Initializer(
|
class Initializer(
|
||||||
val account: Account,
|
val account: Account,
|
||||||
|
|||||||
Reference in New Issue
Block a user