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.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(
@@ -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 {
@@ -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,