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
This commit is contained in:
Claude
2026-03-14 03:44:46 +00:00
parent da38555035
commit 0fe721f426
5 changed files with 897 additions and 0 deletions
@@ -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<Route.NewMarkdownPost> {
MarkdownPostScreen(
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.NewShortNote> {
ShortNotePostScreen(
message = it.message,
@@ -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,
@@ -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
}
}
}
@@ -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<MultiOrchestrator?>(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<User>>(SplitBuilder())
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapRaiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(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<out Event>? {
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<EmojiMedia>?,
): List<EmojiUrlTag> {
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<SelectedMedia>) {
multiOrchestrator = MultiOrchestrator(uris)
}
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
}
+8
View File
@@ -1244,6 +1244,14 @@
<string name="new_community_note">New Community Note</string>
<string name="new_product">New Product</string>
<string name="new_exclusive_geo_note">New Geo-Exclusive Post</string>
<string name="new_long_form_post">New Article</string>
<string name="article_title">Title</string>
<string name="article_summary">Summary (optional)</string>
<string name="article_cover_image_url">Cover image URL (optional)</string>
<string name="write_your_article_in_markdown">Write your article in markdown…</string>
<string name="markdown_preview">Preview</string>
<string name="markdown_edit">Edit</string>
<string name="open_all_reactions_to_this_post">Open all reactions to this post</string>
<string name="close_all_reactions_to_this_post">Close all reactions to this post</string>