From 0fe721f4264321765bce70cb634c19eed0bee075 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 03:44:46 +0000 Subject: [PATCH] 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