From cefda4b1f29dc4a41ef30c87ff2577d7684e82a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 01:36:29 +0000 Subject: [PATCH] Allow uploading the Nest cover image from the gallery Mirrors the Profile Edit banner upload: a SelectSingleFromGallery leadingIcon on the Cover Image field picks media, strips metadata, compresses, and uploads to the user's configured Blossom (or NIP-96) server. The returned URL fills the field so the existing publish path tags it on the kind-30312 MeetingSpaceEvent. --- .../loggedIn/nests/create/CreateNestSheet.kt | 15 +++ .../nests/create/CreateNestViewModel.kt | 110 ++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt index bdad98bae..606851cdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt @@ -53,11 +53,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization 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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.NestActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.NestBridge import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch /** @@ -142,6 +144,19 @@ fun CreateNestSheet( onValueChange = viewModel::onImageUrlChange, label = { Text(stringRes(R.string.nest_create_field_image)) }, singleLine = true, + leadingIcon = { + SelectSingleFromGallery( + isUploading = state.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 5.dp), + ) { media -> + viewModel.uploadForImage( + uri = media, + context = context, + onError = accountViewModel.toastManager::toast, + ) + } + }, modifier = Modifier.fillMaxWidth(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt index cd0613e6f..f4df1b8db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt @@ -20,9 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.create +import android.content.Context import androidx.lifecycle.ViewModel +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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.NestActivity +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.endpoint import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.image @@ -36,6 +48,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlin.coroutines.cancellation.CancellationException /** * Backing ViewModel for [CreateNestSheet]. Holds form state, runs @@ -80,6 +93,102 @@ class CreateNestViewModel : ViewModel() { fun onImageUrlChange(value: String) = _state.update { it.copy(imageUrl = value.trim(), error = null) } + fun uploadForImage( + uri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ) { + val avm = account ?: return + avm.launchSigner { + upload( + galleryUri = uri, + account = avm.account, + context = context, + onError = onError, + )?.let { url -> + _state.update { it.copy(imageUrl = url, error = null) } + } + } + } + + private suspend fun upload( + galleryUri: SelectedMedia, + account: Account, + context: Context, + onError: (String, String) -> Unit, + ): String? { + _state.update { it.copy(isUploadingImage = true) } + try { + val strippingResult = + if (account.settings.stripLocationOnUpload) { + MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) + } else { + null + } + + val sourceUri = + if (account.settings.stripLocationOnUpload && + strippingResult != null && + !strippingResult.stripped + ) { + onError( + stringRes(context, R.string.metadata_strip_failed_title), + stringRes(context, R.string.metadata_strip_failed_upload_cancelled), + ) + return null + } else { + strippingResult?.uri ?: galleryUri.uri + } + + val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + + return 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) { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + stringRes(context, R.string.server_did_not_provide_a_url_after_uploading), + ) + } + + result.url + } catch (e: Exception) { + if (e is CancellationException) throw e + onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) + null + } + } finally { + _state.update { it.copy(isUploadingImage = false) } + } + } + /** * Toggle scheduled-vs-now mode. When [scheduled] is true the * sheet shows a date / time picker and the published event uses @@ -200,6 +309,7 @@ class CreateNestViewModel : ViewModel() { val imageUrl: String, val isPublishing: Boolean, val error: String?, + val isUploadingImage: Boolean = false, /** When true, publish as `status=PLANNED` + `["starts", ]`. */ val scheduled: Boolean = false, /** Unix seconds of the scheduled start; 0 = not yet picked. */