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:
Claude
2026-04-20 20:51:56 +00:00
parent 298e912594
commit 1e2efcdefc
3 changed files with 146 additions and 5 deletions
@@ -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(
@@ -168,6 +168,8 @@ private fun EmojiPackScreenView(
if (showAddDialog) {
AddEmojiDialog(
viewModel = viewModel,
accountViewModel = accountViewModel,
onDismiss = { showAddDialog = false },
onConfirm = { tag, isPrivate ->
accountViewModel.launchSigner {
@@ -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,