Merge emoji image uploader into integration branch

This commit is contained in:
Claude
2026-04-20 21:44:38 +00:00
5 changed files with 269 additions and 7 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,
@@ -37,11 +37,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
@@ -104,7 +106,7 @@ private fun EmojiPackMetadataScaffold(
PackName(viewModel) PackName(viewModel)
Spacer(modifier = DoubleVertSpacer) Spacer(modifier = DoubleVertSpacer)
PackImage(viewModel) PackImage(viewModel, accountViewModel)
Spacer(modifier = DoubleVertSpacer) Spacer(modifier = DoubleVertSpacer)
PackDescription(viewModel) PackDescription(viewModel)
@@ -184,7 +186,10 @@ private fun PackName(viewModel: EmojiPackMetadataViewModel) {
} }
@Composable @Composable
private fun PackImage(viewModel: EmojiPackMetadataViewModel) { private fun PackImage(
viewModel: EmojiPackMetadataViewModel,
accountViewModel: AccountViewModel,
) {
OutlinedTextField( OutlinedTextField(
label = { Text(text = stringRes(R.string.emoji_pack_image_label)) }, label = { Text(text = stringRes(R.string.emoji_pack_image_label)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -196,6 +201,16 @@ private fun PackImage(viewModel: EmojiPackMetadataViewModel) {
color = MaterialTheme.colorScheme.placeholderText, 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)
}
},
) )
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -27,9 +28,24 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel 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.Account
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack 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.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 @Stable
class EmojiPackMetadataViewModel : ViewModel() { class EmojiPackMetadataViewModel : ViewModel() {
@@ -43,6 +59,8 @@ class EmojiPackMetadataViewModel : ViewModel() {
val picture = mutableStateOf(TextFieldValue()) val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue()) val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf { val canPost by derivedStateOf {
name.value.text.isNotBlank() name.value.text.isNotBlank()
} }
@@ -91,4 +109,92 @@ class EmojiPackMetadataViewModel : ViewModel() {
picture.value = TextFieldValue() picture.value = TextFieldValue()
description.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)
}
}
} }