From 0fe721f4264321765bce70cb634c19eed0bee075 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 03:44:46 +0000 Subject: [PATCH 1/3] feat: Add markdown long-form post screen (NIP-23) Add a new post screen specialized for writing and publishing long-form content events (kind 30023) with markdown support. Includes: - MarkdownPostViewModel with title, summary, cover image, and markdown body fields, draft support, and LongTextNoteEvent creation - MarkdownPostScreen with Edit/Preview tabs, monospace editor, markdown preview using existing RenderContentAsMarkdown, and media upload that inserts markdown image syntax - Navigation route (Route.NewMarkdownPost) and AppNavigation entry - String resources for the new screen https://claude.ai/code/session_012pBNvnWtVtQ6aKSheWJUDt --- .../amethyst/ui/navigation/AppNavigation.kt | 10 + .../amethyst/ui/navigation/routes/Routes.kt | 6 + .../loggedIn/home/MarkdownPostScreen.kt | 416 ++++++++++++++++ .../loggedIn/home/MarkdownPostViewModel.kt | 457 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 8 + 5 files changed, 897 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fa59d0dc4..1b53f841b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.MarkdownPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen @@ -310,6 +311,15 @@ fun AppNavigation( ) } + composableFromBottomArgs { + MarkdownPostScreen( + draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ShortNotePostScreen( message = it.message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 24a8e8ad4..70282941d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -278,6 +278,12 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable + data class NewMarkdownPost( + val draft: String? = null, + val version: String? = null, + ) : Route() + @Serializable data class GeoPost( val geohash: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostScreen.kt new file mode 100644 index 000000000..ea579321b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostScreen.kt @@ -0,0 +1,416 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MarkdownPostScreen( + draft: Note? = null, + version: Note? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: MarkdownPostViewModel = viewModel() + postViewModel.init(accountViewModel) + + LaunchedEffect(postViewModel, accountViewModel) { + postViewModel.load(draft, version) + } + + WatchAndLoadMyEmojiList(accountViewModel) + + BackHandler { + accountViewModel.launchSigner { + postViewModel.sendDraftSync() + postViewModel.cancel() + } + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.new_long_form_post, + isActive = postViewModel::canPost, + onPost = { + accountViewModel.launchSigner { + postViewModel.sendPostSync() + nav.popBack() + } + }, + onCancel = { + accountViewModel.launchSigner { + postViewModel.sendDraftSync() + postViewModel.cancel() + } + nav.popBack() + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + MarkdownPostScreenBody(postViewModel, accountViewModel, nav) + } + } +} + +@Composable +private fun MarkdownPostScreenBody( + postViewModel: MarkdownPostViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Title field + OutlinedTextField( + value = postViewModel.title, + onValueChange = { + postViewModel.title = it + postViewModel.draftTag.newVersion() + }, + label = { Text(stringRes(R.string.article_title)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp, vertical = Size5dp), + textStyle = + LocalTextStyle.current.copy( + fontSize = 20.sp, + textDirection = TextDirection.Content, + ), + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Words, + ), + ) + + // Summary field + OutlinedTextField( + value = postViewModel.summary, + onValueChange = { + postViewModel.summary = it + postViewModel.draftTag.newVersion() + }, + label = { Text(stringRes(R.string.article_summary)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp, vertical = Size5dp), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 3, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + // Cover image URL field + OutlinedTextField( + value = postViewModel.coverImageUrl, + onValueChange = { + postViewModel.coverImageUrl = it + postViewModel.draftTag.newVersion() + }, + label = { Text(stringRes(R.string.article_cover_image_url)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp, vertical = Size5dp), + singleLine = true, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = Size5dp)) + + // Edit / Preview tabs + TabRow( + selectedTabIndex = if (postViewModel.showPreview) 1 else 0, + ) { + Tab( + selected = !postViewModel.showPreview, + onClick = { postViewModel.showPreview = false }, + text = { Text(stringRes(R.string.markdown_edit)) }, + ) + Tab( + selected = postViewModel.showPreview, + onClick = { postViewModel.showPreview = true }, + text = { Text(stringRes(R.string.markdown_preview)) }, + ) + } + + if (postViewModel.showPreview) { + // Markdown preview (scrollable) + val previewScrollState = rememberScrollState() + val backgroundColor = remember { mutableStateOf(Color.Transparent) } + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(previewScrollState) + .padding(Size10dp), + ) { + RenderContentAsMarkdown( + content = postViewModel.message.text, + tags = EmptyTagList, + canPreview = true, + quotesLeft = 1, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + // Markdown editor + OutlinedTextField( + value = postViewModel.message, + onValueChange = postViewModel::updateMessage, + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = Size10dp, vertical = Size5dp), + placeholder = { + Text( + text = stringRes(R.string.write_your_article_in_markdown), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + textStyle = + LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + textDirection = TextDirection.Content, + ), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + ), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + } + + // Content warning section + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ContentSensitivityExplainer( + description = postViewModel.contentWarningDescription, + onDescriptionChange = { postViewModel.contentWarningDescription = it }, + ) + } + } + + // Forward zap section + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + ) { + ForwardZapTo(postViewModel, accountViewModel) + } + } + + // Image upload section + postViewModel.multiOrchestrator?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + val context = LocalContext.current + ImageVideoDescription( + it, + accountViewModel.account.settings.defaultFileServer, + onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265) + accountViewModel.account.settings.changeDefaultFileServer(server) + }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, + accountViewModel = accountViewModel, + ) + } + } + + // Zap raiser section + if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ZapRaiserRequest( + stringRes(id = R.string.zapraiser), + postViewModel, + ) + } + } + + // User suggestions + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + modifier = SuggestionListDefaultHeightPage, + ) + } + + // Emoji suggestions + postViewModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + postViewModel::autocompleteWithEmoji, + postViewModel::autocompleteWithEmojiUrl, + modifier = SuggestionListDefaultHeightPage, + ) + } + + // Bottom action bar + MarkdownBottomRowActions(postViewModel) + } +} + +@Composable +private fun MarkdownBottomRowActions(postViewModel: MarkdownPostViewModel) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + SelectFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + SelectFromFiles( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + TakePictureButton( + onPictureTaken = { + postViewModel.selectImage(it) + }, + ) + + ForwardZapToButton(postViewModel.wantsForwardZapTo) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapRaiser) { + postViewModel.wantsZapRaiser = !postViewModel.wantsZapRaiser + } + } + + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { + postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostViewModel.kt new file mode 100644 index 000000000..9fb461bf9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostViewModel.kt @@ -0,0 +1,457 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home + +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.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.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@Stable +class MarkdownPostViewModel : + ViewModel(), + IMessageField, + IZapField, + IZapRaiser { + val draftTag = DraftTagState() + + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + init { + viewModelScope.launch(Dispatchers.IO) { + draftTag.versions.collectLatest { + if (it > 0) { + accountViewModel.launchSigner { + sendDraftSync() + } + } + } + } + } + + var title by mutableStateOf(TextFieldValue("")) + var summary by mutableStateOf(TextFieldValue("")) + var coverImageUrl by mutableStateOf("") + + override var message by mutableStateOf(TextFieldValue("")) + + var showPreview by mutableStateOf(false) + + val iMetaAttachments = IMetaAttachments() + + var isUploadingImage by mutableStateOf(false) + var multiOrchestrator by mutableStateOf(null) + + var userSuggestions: UserSuggestionState? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + var emojiSuggestions: EmojiSuggestionState? = null + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + override var forwardZapTo = mutableStateOf>(SplitBuilder()) + override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapRaiser by mutableStateOf(false) + override val zapRaiserAmount = mutableStateOf(null) + + // Editing existing article + var editingNote: Note? by mutableStateOf(null) + var existingDTag: String? = null + + fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddZapRaiser = accountVM.userProfile().lnAddress() != null + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + } + + fun load( + draft: Note?, + version: Note?, + ) { + val noteEvent = version?.event ?: draft?.event + + if (noteEvent is LongTextNoteEvent) { + title = TextFieldValue(noteEvent.title() ?: "") + summary = TextFieldValue(noteEvent.summary() ?: "") + coverImageUrl = noteEvent.image() ?: "" + message = TextFieldValue(noteEvent.content) + existingDTag = noteEvent.dTag() + editingNote = version ?: draft + } + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + + val version = draftTag.current + cancel() + + if (accountViewModel.settings.isCompleteUIMode()) { + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, emptyList()) + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = accountViewModel.account.client, + ) + accountViewModel.account.consumePostEvent(event, relays, extras) + } + } else { + accountViewModel.account.signAndComputeBroadcast(template, emptyList()) + } + + accountViewModel.launchSigner { + accountViewModel.account.deleteDraftIgnoreErrors(version) + } + } + + suspend fun sendDraftSync() { + if (message.text.isBlank() && title.text.isBlank()) { + accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) + } else { + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, emptySet()) + } + } + + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + private suspend fun createTemplate(): EventTemplate? { + if (title.text.isBlank()) return null + + val tagger = + NewMessageTagger( + message.text, + null, + null, + accountViewModel, + ) + tagger.run() + + val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null + val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null + + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val urls = findURLs(tagger.message) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + + return LongTextNoteEvent.build( + description = tagger.message, + title = title.text.trim(), + summary = summary.text.trim().ifBlank { null }, + image = coverImageUrl.trim().ifBlank { null }, + publishedAt = TimeUtils.now(), + dTag = existingDTag ?: kotlin.uuid.Uuid.random().toString(), + ) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + + private fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } + } + } + + fun upload( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + useH265: Boolean, + ) = try { + uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265) + } catch (_: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + private fun uploadUnsafe( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + useH265: Boolean, + ) { + viewModelScope.launch(Dispatchers.IO) { + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + alt, + contentWarningReason, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + account, + context, + useH265, + ) + + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMeta = + IMetaTagBuilder(state.result.url) + .apply { + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType?.let { mimeType(it) } + state.result.fileHeader.dim?.let { dims(it) } + state.result.fileHeader.blurHash?.let { blurhash(it.blurhash) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } + alt?.let { alt(it) } + contentWarningReason?.let { sensitiveContent(contentWarningReason) } + }.build() + + iMetaAttachments.replace(iMeta.url, iMeta) + + val markdownImage = "![${alt ?: ""}](${state.result.url})" + message = message.insertUrlAtCursor(markdownImage) + } + } + + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + + fun cancel() { + draftTag.rotate() + + title = TextFieldValue("") + summary = TextFieldValue("") + coverImageUrl = "" + message = TextFieldValue("") + showPreview = false + + editingNote = null + existingDTag = null + + multiOrchestrator = null + isUploadingImage = false + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + contentWarningDescription = "" + wantsZapRaiser = false + zapRaiserAmount.value = null + + forwardZapTo.value = SplitBuilder() + forwardZapToEditting.value = TextFieldValue("") + + iMetaAttachments.reset() + + userSuggestions?.reset() + userSuggestionsMainMessage = null + emojiSuggestions?.reset() + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage + + if (message.selection.collapsed) { + val lastWord = newMessage.currentWord() + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } + + emojiSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting.value = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + userSuggestions?.processCurrentWord(lastWord) + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { userSuggestions -> + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.value.addItem(item) + forwardZapToEditting.value = TextFieldValue("") + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + } + + draftTag.newVersion() + } + + fun autocompleteWithEmoji(item: EmojiMedia) { + val wordToInsert = ":${item.code}:" + message = message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.reset() + draftTag.newVersion() + } + + fun autocompleteWithEmojiUrl(item: EmojiMedia) { + val wordToInsert = item.link + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaAttachments.downloadAndPrepare(item.link) { + Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link) + } + } + + message = message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.reset() + draftTag.newVersion() + } + + fun canPost(): Boolean = + title.text.isNotBlank() && + message.text.isNotBlank() && + !isUploadingImage && + (!wantsZapRaiser || zapRaiserAmount.value != null) && + multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) + } + + fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b6e772b0d..73d6b6bf1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1244,6 +1244,14 @@ New Community Note New Product New Geo-Exclusive Post + New Article + + Title + Summary (optional) + Cover image URL (optional) + Write your article in markdown… + Preview + Edit Open all reactions to this post Close all reactions to this post From ffa60c5fd3d0759426fe2faa0db7e6eeac32e346 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 22 Mar 2026 12:25:49 -0400 Subject: [PATCH 2/3] Final touches on new Markdown Screen --- .../amethyst/ui/navigation/AppNavigation.kt | 6 +- .../amethyst/ui/navigation/routes/Routes.kt | 2 +- .../ContentSensitivityExplainer.kt | 2 +- .../expiration/ExpirationDatePicker.kt | 2 +- .../note/creators/invoice/InvoiceRequest.kt | 2 +- .../location/DisplayLocationObserver.kt | 3 + .../secretEmoji/SecretEmojiRequest.kt | 4 +- .../creators/zapraiser/ZapRaiserRequest.kt | 2 +- .../note/creators/zapsplits/ForwardZapTo.kt | 2 +- .../nip22Comments/GenericCommentPostScreen.kt | 44 +- .../loggedIn/discover/DiscoverScreen.kt | 32 +- .../nip23LongForm/LongFormPostScreen.kt | 525 ++++++++++++++++++ .../nip23LongForm/LongFormPostViewModel.kt} | 233 +++++++- .../nip99Classifieds/NewProductScreen.kt | 43 +- .../loggedIn/home/MarkdownPostScreen.kt | 416 -------------- .../loggedIn/home/ShortNotePostScreen.kt | 48 +- 16 files changed, 851 insertions(+), 515 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/{home/MarkdownPostViewModel.kt => discover/nip23LongForm/LongFormPostViewModel.kt} (69%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 1b53f841b..fb210a2ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen @@ -88,7 +89,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.MarkdownPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen @@ -311,8 +311,8 @@ fun AppNavigation( ) } - composableFromBottomArgs { - MarkdownPostScreen( + composableFromBottomArgs { + LongFormPostScreen( draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 70282941d..16d2c3c3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -279,7 +279,7 @@ sealed class Route { ) : Route() @Serializable - data class NewMarkdownPost( + data class NewLongFormPost( val draft: String? = null, val version: String? = null, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt index 77dbf33fc..c407871b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt @@ -59,7 +59,7 @@ fun ContentSensitivityExplainer( modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp), + .padding(bottom = 5.dp), ) { Box( Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt index 967f41058..d494117dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt @@ -99,7 +99,7 @@ fun ExpirationDatePicker(model: IExpiration) { modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp), + .padding(bottom = 5.dp), ) { Icon( imageVector = Icons.Outlined.Timer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt index 1577da5bf..e3f1568b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt @@ -107,7 +107,7 @@ fun InvoiceRequest( Column { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), ) { Icon( imageVector = CustomHashTagIcons.Lightning, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt index 4b2317218..86289842f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -76,6 +77,8 @@ fun DisplayLocationInTitle(geohash: String) { text = cityName, fontSize = 20.sp, fontWeight = FontWeight.W500, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, modifier = Modifier.padding(start = Size5dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt index 0811fdbcd..6126fd837 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt @@ -59,13 +59,13 @@ fun SecretEmojiRequest(onSuccess: (String) -> Unit) { Column { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), ) { Icon( imageVector = Icons.Outlined.Assistant, null, modifier = Size20Modifier, - tint = Color.Unspecified, + tint = MaterialTheme.colorScheme.onBackground, ) Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt index 1f7fbc1f3..60cb55f3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt @@ -54,7 +54,7 @@ fun ZapRaiserRequest( Column { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), ) { Icon( imageVector = CustomHashTagIcons.Lightning, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt index c1da5ee7b..c5d7e1564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt @@ -67,7 +67,7 @@ fun ForwardZapTo( modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp), + .padding(bottom = 5.dp), ) { ZapSplitIcon() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index ed922a496..67e690b83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -83,7 +83,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -255,7 +254,7 @@ private fun GenericCommentPostBody( if (postViewModel.wantsToMarkAsSensitive) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ContentSensitivityExplainer( description = postViewModel.contentWarningDescription, @@ -267,7 +266,7 @@ private fun GenericCommentPostBody( if (postViewModel.wantsExpirationDate) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ExpirationDatePicker(postViewModel) } @@ -276,7 +275,7 @@ private fun GenericCommentPostBody( if (postViewModel.wantsToAddGeoHash) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { LocationAsHash(postViewModel) } @@ -285,7 +284,7 @@ private fun GenericCommentPostBody( if (postViewModel.wantsForwardZapTo) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ForwardZapTo(postViewModel, accountViewModel) } @@ -294,7 +293,7 @@ private fun GenericCommentPostBody( postViewModel.multiOrchestrator?.let { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { val context = LocalContext.current ImageVideoDescription( @@ -313,25 +312,30 @@ private fun GenericCommentPostBody( if (postViewModel.wantsInvoice) { postViewModel.lnAddress()?.let { lud16 -> - InvoiceRequest( - lud16, - accountViewModel.account.userProfile(), - accountViewModel, - stringRes(id = R.string.lightning_invoice), - stringRes(id = R.string.lightning_create_and_add_invoice), - onNewInvoice = { - postViewModel.insertAtCursor(it) - postViewModel.wantsInvoice = false - }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, - ) + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + InvoiceRequest( + lud16, + accountViewModel.account.userProfile(), + accountViewModel, + stringRes(id = R.string.lightning_invoice), + stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + ) + } } } if (postViewModel.wantsSecretEmoji) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { Column(Modifier.fillMaxWidth()) { SecretEmojiRequest { @@ -345,7 +349,7 @@ private fun GenericCommentPostBody( if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ZapRaiserRequest( stringRes(id = R.string.zapraiser), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index cb02b626c..3a59adf08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -252,8 +252,14 @@ private fun DiscoverPages( }, floatingButton = { val currentPage = pagerState.currentPage - if (currentPage >= 0 && currentPage < feedTabs.size && feedTabs[currentPage].resource == R.string.discover_marketplace) { - NewProductButton(accountViewModel, nav) + if (currentPage >= 0 && currentPage < feedTabs.size) { + if (feedTabs[currentPage].resource == R.string.discover_marketplace) { + NewProductButton(accountViewModel, nav) + } + + if (feedTabs[currentPage].resource == R.string.discover_reads) { + NewLongFormMarkdownButton(accountViewModel, nav) + } } }, accountViewModel = accountViewModel, @@ -357,6 +363,28 @@ fun NewProductButton( } } +@Composable +fun NewLongFormMarkdownButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.NewLongFormPost()) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringRes(id = R.string.new_long_form_post), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} + @Composable private fun RenderDiscoverFeed( feedContentState: FeedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt new file mode 100644 index 000000000..aaaa385b2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -0,0 +1,525 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer +import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton +import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton +import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest +import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton +import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest +import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ObserveInboxRelayListAndDisplayIfNotFound +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LongFormPostScreen( + draft: Note? = null, + version: Note? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: LongFormPostViewModel = viewModel() + postViewModel.init(accountViewModel) + + LaunchedEffect(postViewModel, accountViewModel) { + postViewModel.load(draft, version) + } + + WatchAndLoadMyEmojiList(accountViewModel) + + BackHandler { + accountViewModel.launchSigner { + postViewModel.sendDraftSync() + postViewModel.cancel() + } + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.new_long_form_post, + isActive = postViewModel::canPost, + onPost = { + accountViewModel.launchSigner { + postViewModel.sendPostSync() + nav.popBack() + } + }, + onCancel = { + accountViewModel.launchSigner { + postViewModel.sendDraftSync() + postViewModel.cancel() + } + nav.popBack() + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + MarkdownPostScreenBody(postViewModel, accountViewModel, nav) + } + } +} + +@Composable +private fun MarkdownPostScreenBody( + postViewModel: LongFormPostViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val scrollState = rememberScrollState() + Column( + modifier = + Modifier.fillMaxSize(), + ) { + ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = Size10dp, + end = Size10dp, + ).weight(1f), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(scrollState, reverseScrolling = true), + ) { + // Title field + OutlinedTextField( + value = postViewModel.title, + onValueChange = { + postViewModel.title = it + postViewModel.draftTag.newVersion() + }, + label = { Text(stringRes(R.string.article_title)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp, vertical = Size5dp), + textStyle = + LocalTextStyle.current.copy( + fontSize = 20.sp, + textDirection = TextDirection.Content, + ), + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Words, + ), + ) + + // Summary field + OutlinedTextField( + value = postViewModel.summary, + onValueChange = { + postViewModel.summary = it + postViewModel.draftTag.newVersion() + }, + label = { Text(stringRes(R.string.article_summary)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp, vertical = Size5dp), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 3, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + // Cover image URL field + OutlinedTextField( + value = postViewModel.coverImageUrl, + onValueChange = { + postViewModel.coverImageUrl = it + postViewModel.draftTag.newVersion() + }, + label = { Text(stringRes(R.string.article_cover_image_url)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp, vertical = Size5dp), + singleLine = true, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = Size5dp)) + + // Edit / Preview tabs + TabRow( + selectedTabIndex = if (postViewModel.showPreview) 1 else 0, + ) { + Tab( + selected = !postViewModel.showPreview, + onClick = { postViewModel.showPreview = false }, + text = { Text(stringRes(R.string.markdown_edit)) }, + ) + Tab( + selected = postViewModel.showPreview, + onClick = { postViewModel.showPreview = true }, + text = { Text(stringRes(R.string.markdown_preview)) }, + ) + } + + if (postViewModel.showPreview) { + // Markdown preview (scrollable) + val backgroundColor = remember { mutableStateOf(Color.Transparent) } + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(Size10dp), + ) { + RenderContentAsMarkdown( + content = postViewModel.message.text, + tags = EmptyTagList, + canPreview = true, + quotesLeft = 1, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + // Markdown editor + OutlinedTextField( + value = postViewModel.message, + onValueChange = postViewModel::updateMessage, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp, vertical = Size5dp), + placeholder = { + Text( + text = stringRes(R.string.write_your_article_in_markdown), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + textStyle = + LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + textDirection = TextDirection.Content, + ), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + } + + // Content warning section + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + ContentSensitivityExplainer( + description = postViewModel.contentWarningDescription, + onDescriptionChange = { postViewModel.contentWarningDescription = it }, + ) + } + } + + if (postViewModel.wantsExpirationDate) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + ExpirationDatePicker(postViewModel) + } + } + + if (postViewModel.wantsToAddGeoHash) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + LocationAsHash(postViewModel) { + SettingsRow( + R.string.geohash_exclusive, + R.string.geohash_exclusive_explainer, + ) { + Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) + } + } + } + } + + // Forward zap section + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + ForwardZapTo(postViewModel, accountViewModel) + } + } + + // Image upload section + postViewModel.multiOrchestrator?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + val context = LocalContext.current + ImageVideoDescription( + it, + accountViewModel.account.settings.defaultFileServer, + onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265) + accountViewModel.account.settings.changeDefaultFileServer(server) + }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, + accountViewModel = accountViewModel, + ) + } + } + + if (postViewModel.wantsInvoice) { + postViewModel.lnAddress()?.let { lud16 -> + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + InvoiceRequest( + lud16, + accountViewModel.account.userProfile(), + accountViewModel, + stringRes(id = R.string.lightning_invoice), + stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + ) + } + } + } + + if (postViewModel.wantsSecretEmoji) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + SecretEmojiRequest { + postViewModel.insertAtCursor(it) + postViewModel.wantsSecretEmoji = false + } + } + } + } + + // Zap raiser section + if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + ZapRaiserRequest( + stringRes(id = R.string.zapraiser), + postViewModel, + ) + } + } + } + } + + // User suggestions + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + modifier = SuggestionListDefaultHeightPage, + ) + } + + // Emoji suggestions + postViewModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + postViewModel::autocompleteWithEmoji, + postViewModel::autocompleteWithEmojiUrl, + modifier = SuggestionListDefaultHeightPage, + ) + } + + // Bottom action bar + MarkdownBottomRowActions(postViewModel) + } +} + +@Composable +private fun MarkdownBottomRowActions(postViewModel: LongFormPostViewModel) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + SelectFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + SelectFromFiles( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + TakePictureButton( + onPictureTaken = { + postViewModel.selectImage(it) + }, + ) + + ForwardZapToButton(postViewModel.wantsForwardZapTo) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } + + if (postViewModel.canAddZapRaiser) { + AddZapraiserButton(postViewModel.wantsZapRaiser) { + postViewModel.wantsZapRaiser = !postViewModel.wantsZapRaiser + } + } + + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { + postViewModel.toggleMarkAsSensitive() + } + + ExpirationDateButton(postViewModel.wantsExpirationDate) { + postViewModel.toggleExpirationDate() + } + + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { + postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash + } + + AddSecretEmojiButton(postViewModel.wantsSecretEmoji) { + postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji + } + + if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 9fb461bf9..803e5e7f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -18,11 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm import android.content.Context import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue @@ -35,8 +36,10 @@ import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator @@ -46,6 +49,8 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration +import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser @@ -54,23 +59,33 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -83,18 +98,23 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlin.uuid.ExperimentalUuidApi @Stable -class MarkdownPostViewModel : +class LongFormPostViewModel : ViewModel(), + ILocationGrabber, IMessageField, IZapField, - IZapRaiser { + IZapRaiser, + IExpiration { val draftTag = DraftTagState() lateinit var accountViewModel: AccountViewModel @@ -103,6 +123,7 @@ class MarkdownPostViewModel : init { viewModelScope.launch(Dispatchers.IO) { draftTag.versions.collectLatest { + // don't save the first if (it > 0) { accountViewModel.launchSigner { sendDraftSync() @@ -115,6 +136,7 @@ class MarkdownPostViewModel : var title by mutableStateOf(TextFieldValue("")) var summary by mutableStateOf(TextFieldValue("")) var coverImageUrl by mutableStateOf("") + var publishedAt by mutableLongStateOf(TimeUtils.now()) override var message by mutableStateOf(TextFieldValue("")) @@ -127,22 +149,42 @@ class MarkdownPostViewModel : var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null + var emojiSuggestions: EmojiSuggestionState? = null - // NSFW, Sensitive - var wantsToMarkAsSensitive by mutableStateOf(false) - var contentWarningDescription by mutableStateOf("") + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + var wantsSecretEmoji by mutableStateOf(false) // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) override var forwardZapTo = mutableStateOf>(SplitBuilder()) override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") + + // Expiration Date (NIP-40) + var wantsExpirationDate by mutableStateOf(false) + override var expirationDate by mutableLongStateOf(TimeUtils.oneDayAhead()) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + var wantsExclusiveGeoPost by mutableStateOf(false) + // ZapRaiser var canAddZapRaiser by mutableStateOf(false) var wantsZapRaiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) + fun lnAddress(): String? = account.userProfile().lnAddress() + + fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null + // Editing existing article var editingNote: Note? by mutableStateOf(null) var existingDTag: String? = null @@ -150,7 +192,8 @@ class MarkdownPostViewModel : fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM this.account = accountVM.account - this.canAddZapRaiser = accountVM.userProfile().lnAddress() != null + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) @@ -163,18 +206,91 @@ class MarkdownPostViewModel : draft: Note?, version: Note?, ) { - val noteEvent = version?.event ?: draft?.event + val noteEvent = draft?.event + val noteAuthor = draft?.author - if (noteEvent is LongTextNoteEvent) { - title = TextFieldValue(noteEvent.title() ?: "") - summary = TextFieldValue(noteEvent.summary() ?: "") - coverImageUrl = noteEvent.image() ?: "" - message = TextFieldValue(noteEvent.content) - existingDTag = noteEvent.dTag() - editingNote = version ?: draft + if (draft != null && noteEvent is DraftWrapEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) + } + loadFromDraft(innerNote) + } + } + } else { + val noteEvent = version?.event + + if (noteEvent is LongTextNoteEvent) { + title = TextFieldValue(noteEvent.title() ?: "") + summary = TextFieldValue(noteEvent.summary() ?: "") + publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt + coverImageUrl = noteEvent.image() ?: "" + message = TextFieldValue(noteEvent.content) + existingDTag = noteEvent.dTag() + editingNote = version + } + + val user = account.userProfile() + + canAddInvoice = user.lnAddress() != null + canAddZapRaiser = user.lnAddress() != null + multiOrchestrator = null } } + private fun loadFromDraft(draft: Note) { + val draftEvent = draft.event ?: return + if (draftEvent is LongTextNoteEvent) { + loadFromDraft(draftEvent) + } + } + + private fun loadFromDraft(draftEvent: LongTextNoteEvent) { + canAddInvoice = accountViewModel.userProfile().lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null + multiOrchestrator = null + + val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + forwardZapTo.value = SplitBuilder() + localForwardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.value.addItem(user, value) + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" + + val draftExpiration = draftEvent.tags.expiration() + wantsExpirationDate = draftExpiration != null + expirationDate = draftExpiration ?: TimeUtils.oneDayAhead() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + if (geohash != null) { + wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND + } + + val zapRaiser = draftEvent.zapraiserAmount() + wantsZapRaiser = zapRaiser != null + zapRaiserAmount.value = null + if (zapRaiser != null) { + zapRaiserAmount.value = zapRaiser + } + + if (forwardZapTo.value.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + message = TextFieldValue(draftEvent.content) + + iMetaAttachments.addAll(draftEvent.imetas()) + } + suspend fun sendPostSync() { val template = createTemplate() ?: return @@ -209,7 +325,7 @@ class MarkdownPostViewModel : } } - @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + @OptIn(ExperimentalUuidApi::class) private suspend fun createTemplate(): EventTemplate? { if (title.text.isBlank()) return null @@ -223,28 +339,34 @@ class MarkdownPostViewModel : tagger.run() val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null + + val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null - val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null + val localExpirationDate = if (wantsExpirationDate) expirationDate else null + return LongTextNoteEvent.build( description = tagger.message, title = title.text.trim(), summary = summary.text.trim().ifBlank { null }, image = coverImageUrl.trim().ifBlank { null }, - publishedAt = TimeUtils.now(), - dTag = existingDTag ?: kotlin.uuid.Uuid.random().toString(), + publishedAt = publishedAt, + dTag = existingDTag ?: RandomInstance.randomChars(16), ) { hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + geoHash?.let { geohash(it) } localZapRaiserAmount?.let { zapraiser(it) } zapReceiver?.let { zapSplits(it) } contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } emojis(emojis) imetas(usedAttachments) @@ -311,9 +433,12 @@ class MarkdownPostViewModel : .apply { hash(state.result.fileHeader.hash) size(state.result.fileHeader.size) - state.result.fileHeader.mimeType?.let { mimeType(it) } - state.result.fileHeader.dim?.let { dims(it) } - state.result.fileHeader.blurHash?.let { blurhash(it.blurhash) } + state.result.fileHeader.mimeType + ?.let { mimeType(it) } + state.result.fileHeader.dim + ?.let { dims(it) } + state.result.fileHeader.blurHash + ?.let { blurhash(it.blurhash) } state.result.magnet?.let { magnet(it) } state.result.uploadedHash?.let { originalHash(it) } alt?.let { alt(it) } @@ -344,6 +469,7 @@ class MarkdownPostViewModel : summary = TextFieldValue("") coverImageUrl = "" message = TextFieldValue("") + publishedAt = TimeUtils.now() showPreview = false editingNote = null @@ -352,11 +478,16 @@ class MarkdownPostViewModel : multiOrchestrator = null isUploadingImage = false + wantsInvoice = false + wantsZapRaiser = false + zapRaiserAmount.value = null + wantsForwardZapTo = false wantsToMarkAsSensitive = false contentWarningDescription = "" - wantsZapRaiser = false - zapRaiserAmount.value = null + wantsToAddGeoHash = false + wantsExclusiveGeoPost = false + wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() forwardZapToEditting.value = TextFieldValue("") @@ -442,6 +573,7 @@ class MarkdownPostViewModel : title.text.isNotBlank() && message.text.isNotBlank() && !isUploadingImage && + !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && multiOrchestrator == null @@ -453,5 +585,56 @@ class MarkdownPostViewModel : multiOrchestrator = MultiOrchestrator(uris) } - fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null + override fun locationFlow(): StateFlow { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + override fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.value.updatePercentage(index, sliderValue) + } + + override fun updateZapFromText() { + viewModelScope.launch(Dispatchers.IO) { + val tagger = + NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.value.items.any { it.key == taggedUser }) { + forwardZapTo.value.addItem(taggedUser) + } + } + } + } + + override fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount.value = newAmount + draftTag.newVersion() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + draftTag.newVersion() + } + + fun toggleExpirationDate() { + wantsExpirationDate = !wantsExpirationDate + if (wantsExpirationDate) { + expirationDate = TimeUtils.oneDayAhead() + } + draftTag.newVersion() + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index aa651348e..47fae49aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -223,7 +223,7 @@ private fun NewProductBody( if (postViewModel.wantsToMarkAsSensitive) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ContentSensitivityExplainer( description = postViewModel.contentWarningDescription, @@ -235,7 +235,7 @@ private fun NewProductBody( if (postViewModel.wantsExpirationDate) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ExpirationDatePicker(postViewModel) } @@ -244,7 +244,7 @@ private fun NewProductBody( if (postViewModel.wantsToAddGeoHash) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { LocationAsHash(postViewModel) } @@ -253,7 +253,7 @@ private fun NewProductBody( if (postViewModel.wantsForwardZapTo) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ForwardZapTo(postViewModel, accountViewModel) } @@ -262,7 +262,7 @@ private fun NewProductBody( postViewModel.multiOrchestrator?.let { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { val context = LocalContext.current @@ -282,25 +282,30 @@ private fun NewProductBody( if (postViewModel.wantsInvoice) { postViewModel.lnAddress()?.let { lud16 -> - InvoiceRequest( - lud16, - accountViewModel.account.userProfile(), - accountViewModel, - stringRes(id = R.string.lightning_invoice), - stringRes(id = R.string.lightning_create_and_add_invoice), - onNewInvoice = { - postViewModel.insertAtCursor(it) - postViewModel.wantsInvoice = false - }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, - ) + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + InvoiceRequest( + lud16, + accountViewModel.account.userProfile(), + accountViewModel, + stringRes(id = R.string.lightning_invoice), + stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + ) + } } } if (postViewModel.wantsSecretEmoji) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { Column(Modifier.fillMaxWidth()) { SecretEmojiRequest { @@ -314,7 +319,7 @@ private fun NewProductBody( if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ZapRaiserRequest( stringRes(id = R.string.zapraiser), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostScreen.kt deleted file mode 100644 index ea579321b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/MarkdownPostScreen.kt +++ /dev/null @@ -1,416 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.home - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery -import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton -import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown -import com.vitorpamplona.amethyst.ui.navigation.navs.Nav -import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar -import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer -import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList -import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton -import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo -import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MarkdownPostScreen( - draft: Note? = null, - version: Note? = null, - accountViewModel: AccountViewModel, - nav: Nav, -) { - val postViewModel: MarkdownPostViewModel = viewModel() - postViewModel.init(accountViewModel) - - LaunchedEffect(postViewModel, accountViewModel) { - postViewModel.load(draft, version) - } - - WatchAndLoadMyEmojiList(accountViewModel) - - BackHandler { - accountViewModel.launchSigner { - postViewModel.sendDraftSync() - postViewModel.cancel() - } - nav.popBack() - } - - Scaffold( - topBar = { - PostingTopBar( - titleRes = R.string.new_long_form_post, - isActive = postViewModel::canPost, - onPost = { - accountViewModel.launchSigner { - postViewModel.sendPostSync() - nav.popBack() - } - }, - onCancel = { - accountViewModel.launchSigner { - postViewModel.sendDraftSync() - postViewModel.cancel() - } - nav.popBack() - }, - ) - }, - ) { pad -> - Surface( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .imePadding(), - ) { - MarkdownPostScreenBody(postViewModel, accountViewModel, nav) - } - } -} - -@Composable -private fun MarkdownPostScreenBody( - postViewModel: MarkdownPostViewModel, - accountViewModel: AccountViewModel, - nav: Nav, -) { - Column( - modifier = Modifier.fillMaxSize(), - ) { - // Title field - OutlinedTextField( - value = postViewModel.title, - onValueChange = { - postViewModel.title = it - postViewModel.draftTag.newVersion() - }, - label = { Text(stringRes(R.string.article_title)) }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = Size10dp, vertical = Size5dp), - textStyle = - LocalTextStyle.current.copy( - fontSize = 20.sp, - textDirection = TextDirection.Content, - ), - singleLine = true, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Words, - ), - ) - - // Summary field - OutlinedTextField( - value = postViewModel.summary, - onValueChange = { - postViewModel.summary = it - postViewModel.draftTag.newVersion() - }, - label = { Text(stringRes(R.string.article_summary)) }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = Size10dp, vertical = Size5dp), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 3, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - ) - - // Cover image URL field - OutlinedTextField( - value = postViewModel.coverImageUrl, - onValueChange = { - postViewModel.coverImageUrl = it - postViewModel.draftTag.newVersion() - }, - label = { Text(stringRes(R.string.article_cover_image_url)) }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = Size10dp, vertical = Size5dp), - singleLine = true, - ) - - HorizontalDivider(modifier = Modifier.padding(vertical = Size5dp)) - - // Edit / Preview tabs - TabRow( - selectedTabIndex = if (postViewModel.showPreview) 1 else 0, - ) { - Tab( - selected = !postViewModel.showPreview, - onClick = { postViewModel.showPreview = false }, - text = { Text(stringRes(R.string.markdown_edit)) }, - ) - Tab( - selected = postViewModel.showPreview, - onClick = { postViewModel.showPreview = true }, - text = { Text(stringRes(R.string.markdown_preview)) }, - ) - } - - if (postViewModel.showPreview) { - // Markdown preview (scrollable) - val previewScrollState = rememberScrollState() - val backgroundColor = remember { mutableStateOf(Color.Transparent) } - Column( - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(previewScrollState) - .padding(Size10dp), - ) { - RenderContentAsMarkdown( - content = postViewModel.message.text, - tags = EmptyTagList, - canPreview = true, - quotesLeft = 1, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } else { - // Markdown editor - OutlinedTextField( - value = postViewModel.message, - onValueChange = postViewModel::updateMessage, - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .padding(horizontal = Size10dp, vertical = Size5dp), - placeholder = { - Text( - text = stringRes(R.string.write_your_article_in_markdown), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - textStyle = - LocalTextStyle.current.copy( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - textDirection = TextDirection.Content, - ), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - ), - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - ) - } - - // Content warning section - if (postViewModel.wantsToMarkAsSensitive) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - ContentSensitivityExplainer( - description = postViewModel.contentWarningDescription, - onDescriptionChange = { postViewModel.contentWarningDescription = it }, - ) - } - } - - // Forward zap section - if (postViewModel.wantsForwardZapTo) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), - ) { - ForwardZapTo(postViewModel, accountViewModel) - } - } - - // Image upload section - postViewModel.multiOrchestrator?.let { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - val context = LocalContext.current - ImageVideoDescription( - it, - accountViewModel.account.settings.defaultFileServer, - onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 -> - postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265) - accountViewModel.account.settings.changeDefaultFileServer(server) - }, - onDelete = postViewModel::deleteMediaToUpload, - onCancel = { postViewModel.multiOrchestrator = null }, - accountViewModel = accountViewModel, - ) - } - } - - // Zap raiser section - if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - ZapRaiserRequest( - stringRes(id = R.string.zapraiser), - postViewModel, - ) - } - } - - // User suggestions - postViewModel.userSuggestions?.let { - ShowUserSuggestionList( - it, - postViewModel::autocompleteWithUser, - accountViewModel, - modifier = SuggestionListDefaultHeightPage, - ) - } - - // Emoji suggestions - postViewModel.emojiSuggestions?.let { - ShowEmojiSuggestionList( - it, - postViewModel::autocompleteWithEmoji, - postViewModel::autocompleteWithEmojiUrl, - modifier = SuggestionListDefaultHeightPage, - ) - } - - // Bottom action bar - MarkdownBottomRowActions(postViewModel) - } -} - -@Composable -private fun MarkdownBottomRowActions(postViewModel: MarkdownPostViewModel) { - val scrollState = rememberScrollState() - Row( - modifier = - Modifier - .horizontalScroll(scrollState) - .fillMaxWidth() - .height(50.dp), - verticalAlignment = CenterVertically, - ) { - SelectFromGallery( - isUploading = postViewModel.isUploadingImage, - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, - ) { - postViewModel.selectImage(it) - } - - SelectFromFiles( - isUploading = postViewModel.isUploadingImage, - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, - ) { - postViewModel.selectImage(it) - } - - TakePictureButton( - onPictureTaken = { - postViewModel.selectImage(it) - }, - ) - - ForwardZapToButton(postViewModel.wantsForwardZapTo) { - postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo - } - - if (postViewModel.canAddZapRaiser) { - AddZapraiserButton(postViewModel.wantsZapRaiser) { - postViewModel.wantsZapRaiser = !postViewModel.wantsZapRaiser - } - } - - MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { - postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 8f799892e..73a722af6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -104,7 +104,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @@ -293,7 +292,7 @@ private fun NewPostScreenBody( if (postViewModel.wantsPoll) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { PollOptionsField(postViewModel) } @@ -304,7 +303,7 @@ private fun NewPostScreenBody( if (postViewModel.wantsToMarkAsSensitive) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ContentSensitivityExplainer( description = postViewModel.contentWarningDescription, @@ -316,7 +315,7 @@ private fun NewPostScreenBody( if (postViewModel.wantsExpirationDate) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ExpirationDatePicker(postViewModel) } @@ -325,7 +324,7 @@ private fun NewPostScreenBody( if (postViewModel.wantsToAddGeoHash) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { LocationAsHash(postViewModel) { SettingsRow( @@ -341,7 +340,7 @@ private fun NewPostScreenBody( if (postViewModel.wantsForwardZapTo) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ForwardZapTo(postViewModel, accountViewModel) } @@ -350,7 +349,7 @@ private fun NewPostScreenBody( postViewModel.multiOrchestrator?.let { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { val context = LocalContext.current ImageVideoDescription( @@ -378,7 +377,7 @@ private fun NewPostScreenBody( modifier = Modifier .fillMaxWidth() - .padding(vertical = Size5dp, horizontal = Size10dp), + .padding(vertical = Size10dp, horizontal = Size10dp), ) { // Display voice preview or uploading progress postViewModel.voiceOrchestrator?.let { orchestrator -> @@ -416,25 +415,30 @@ private fun NewPostScreenBody( if (postViewModel.wantsInvoice) { postViewModel.lnAddress()?.let { lud16 -> - InvoiceRequest( - lud16, - accountViewModel.account.userProfile(), - accountViewModel, - stringRes(id = R.string.lightning_invoice), - stringRes(id = R.string.lightning_create_and_add_invoice), - onNewInvoice = { - postViewModel.insertAtCursor(it) - postViewModel.wantsInvoice = false - }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, - ) + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), + ) { + InvoiceRequest( + lud16, + accountViewModel.account.userProfile(), + accountViewModel, + stringRes(id = R.string.lightning_invoice), + stringRes(id = R.string.lightning_create_and_add_invoice), + onNewInvoice = { + postViewModel.insertAtCursor(it) + postViewModel.wantsInvoice = false + }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + ) + } } } if (postViewModel.wantsSecretEmoji) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { Column(Modifier.fillMaxWidth()) { SecretEmojiRequest { @@ -448,7 +452,7 @@ private fun NewPostScreenBody( if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) { Row( verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { ZapRaiserRequest( stringRes(id = R.string.zapraiser), From 26a3dcbd85354e368e6488e66ed6c900b8c38f5e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 22 Mar 2026 12:35:19 -0400 Subject: [PATCH 3/3] Adds upload cover url --- .../nip23LongForm/LongFormPostScreen.kt | 18 +++++ .../nip23LongForm/LongFormPostViewModel.kt | 76 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index aaaa385b2..a56dd41af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @@ -226,6 +227,8 @@ private fun MarkdownPostScreenBody( ), ) + val context = LocalContext.current + // Cover image URL field OutlinedTextField( value = postViewModel.coverImageUrl, @@ -239,6 +242,21 @@ private fun MarkdownPostScreenBody( .fillMaxWidth() .padding(horizontal = Size10dp, vertical = Size5dp), singleLine = true, + placeholder = { + Text( + text = "https://mywebsite.com/mypost.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + SelectSingleFromGallery( + isUploading = postViewModel.isUploadingCoverImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 5.dp), + ) { + postViewModel.uploadCoverImage(it, context, onError = accountViewModel.toastManager::toast) + } + }, ) HorizontalDivider(modifier = Modifier.padding(vertical = Size5dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 803e5e7f3..a9da009eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -40,11 +40,15 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -105,6 +109,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException import kotlin.uuid.ExperimentalUuidApi @Stable @@ -138,6 +143,8 @@ class LongFormPostViewModel : var coverImageUrl by mutableStateOf("") var publishedAt by mutableLongStateOf(TimeUtils.now()) + var isUploadingCoverImage by mutableStateOf(false) + override var message by mutableStateOf(TextFieldValue("")) var showPreview by mutableStateOf(false) @@ -383,6 +390,75 @@ class LongFormPostViewModel : } } + fun uploadCoverImage( + uri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ) { + accountViewModel.launchSigner { + isUploadingCoverImage = true + try { + directUpload( + galleryUri = uri, + context = context, + onError = onError, + )?.let { + coverImageUrl = it + } + } finally { + isUploadingCoverImage = false + } + } + } + + private suspend fun directUpload( + galleryUri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ): String? { + val compResult = MediaCompressor().compress(galleryUri.uri, 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 + } + } + fun upload( alt: String?, contentWarningReason: String?,