Merge pull request #1901 from vitorpamplona/claude/add-markdown-post-screen-Xma8x

Add NIP-23 Long-Form Content (Markdown Articles) Support
This commit is contained in:
Vitor Pamplona
2026-03-22 12:38:14 -04:00
committed by GitHub
16 changed files with 1397 additions and 70 deletions
@@ -81,6 +81,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
@@ -333,6 +334,15 @@ fun AppNavigation(
)
}
composableFromBottomArgs<Route.NewLongFormPost> {
LongFormPostScreen(
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,
@@ -288,6 +288,12 @@ sealed class Route {
val draft: String? = null,
) : Route()
@Serializable
data class NewLongFormPost(
val draft: String? = null,
val version: String? = null,
) : Route()
@Serializable
data class GeoPost(
val geohash: String? = null,
@@ -59,7 +59,7 @@ fun ContentSensitivityExplainer(
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
.padding(bottom = 5.dp),
) {
Box(
Modifier
@@ -99,7 +99,7 @@ fun ExpirationDatePicker(model: IExpiration) {
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
.padding(bottom = 5.dp),
) {
Icon(
imageVector = Icons.Outlined.Timer,
@@ -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,
@@ -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),
)
}
@@ -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(
@@ -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,
@@ -67,7 +67,7 @@ fun ForwardZapTo(
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
.padding(bottom = 5.dp),
) {
ZapSplitIcon()
@@ -84,7 +84,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
@@ -258,7 +257,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,
@@ -270,7 +269,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)
}
@@ -279,7 +278,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)
}
@@ -288,7 +287,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)
}
@@ -297,7 +296,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(
@@ -317,25 +316,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 {
@@ -349,7 +353,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),
@@ -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,
@@ -0,0 +1,543 @@
/*
* 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.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
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,
),
)
val context = LocalContext.current
// 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,
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))
// 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
}
}
}
}
@@ -0,0 +1,716 @@
/*
* 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 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
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.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
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
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.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
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.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.coroutines.cancellation.CancellationException
import kotlin.uuid.ExperimentalUuidApi
@Stable
class LongFormPostViewModel :
ViewModel(),
ILocationGrabber,
IMessageField,
IZapField,
IZapRaiser,
IExpiration {
val draftTag = DraftTagState()
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
init {
viewModelScope.launch(Dispatchers.IO) {
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
accountViewModel.launchSigner {
sendDraftSync()
}
}
}
}
}
var title by mutableStateOf(TextFieldValue(""))
var summary by mutableStateOf(TextFieldValue(""))
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)
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
// 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<User>>(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<LocationState.LocationResult>? = null
var wantsExclusiveGeoPost by mutableStateOf(false)
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapRaiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(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
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
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 = draft?.event
val noteAuthor = draft?.author
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
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(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 geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value 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 = 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)
}
}
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 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?,
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("")
publishedAt = TimeUtils.now()
showPreview = false
editingNote = null
existingDTag = null
multiOrchestrator = null
isUploadingImage = false
wantsInvoice = false
wantsZapRaiser = false
zapRaiserAmount.value = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
contentWarningDescription = ""
wantsToAddGeoHash = false
wantsExclusiveGeoPost = false
wantsSecretEmoji = false
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 &&
!wantsInvoice &&
(!wantsZapRaiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
multiOrchestrator = MultiOrchestrator(uris)
}
override fun locationFlow(): StateFlow<LocationState.LocationResult> {
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
}
@@ -226,7 +226,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,
@@ -238,7 +238,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)
}
@@ -247,7 +247,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)
}
@@ -256,7 +256,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)
}
@@ -265,7 +265,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
@@ -286,25 +286,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 {
@@ -318,7 +323,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),
@@ -108,7 +108,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
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
@@ -299,7 +298,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)
}
@@ -319,7 +318,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,
@@ -331,7 +330,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)
}
@@ -340,7 +339,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(
@@ -356,7 +355,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)
}
@@ -365,7 +364,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(
@@ -394,7 +393,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 ->
@@ -432,25 +431,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 {
@@ -464,7 +468,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),
+8
View File
@@ -1292,6 +1292,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>