From 298e91259487e32458aefef66e4eaf07a4517b2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:51:36 +0000 Subject: [PATCH 1/2] feat(emoji): upload cover image in emoji pack metadata screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the NIP-96/Blossom uploader plumbing from BookmarkGroupMetadata — the cover image field now shows a gallery-picker icon on its leading side and populates the URL on upload success. The manual URL input still works as before. https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn --- .../list/metadata/EmojiPackMetadataScreen.kt | 19 +++- .../metadata/EmojiPackMetadataViewModel.kt | 106 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt index 8668d0ebe..e909c2c35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt @@ -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) + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt index f5b144839..739538384 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt @@ -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) + } + } } From 1e2efcdefc2fa86c41e647e5df36fa2595c9a123 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:51:56 +0000 Subject: [PATCH 2/2] feat(emoji): upload emoji image inline in AddEmojiDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../emojipacks/display/AddEmojiDialog.kt | 32 ++++- .../emojipacks/display/EmojiPackScreen.kt | 2 + .../emojipacks/display/EmojiPackViewModel.kt | 117 ++++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt index 9f826275d..4c4493b39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt index aab921aa1..07ce91323 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt @@ -168,6 +168,8 @@ private fun EmojiPackScreenView( if (showAddDialog) { AddEmojiDialog( + viewModel = viewModel, + accountViewModel = accountViewModel, onDismiss = { showAddDialog = false }, onConfirm = { tag, isPrivate -> accountViewModel.launchSigner { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt index 707b4ba60..54380e46b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt @@ -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,