Final touches on new Markdown Screen

This commit is contained in:
Vitor Pamplona
2026-03-22 12:25:49 -04:00
parent 0fe721f426
commit ffa60c5fd3
16 changed files with 851 additions and 515 deletions
@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
@@ -88,7 +89,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.MarkdownPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen
@@ -311,8 +311,8 @@ fun AppNavigation(
)
}
composableFromBottomArgs<Route.NewMarkdownPost> {
MarkdownPostScreen(
composableFromBottomArgs<Route.NewLongFormPost> {
LongFormPostScreen(
draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
accountViewModel = accountViewModel,
@@ -279,7 +279,7 @@ sealed class Route {
) : Route()
@Serializable
data class NewMarkdownPost(
data class NewLongFormPost(
val draft: String? = null,
val version: String? = null,
) : Route()
@@ -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()
@@ -83,7 +83,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -255,7 +254,7 @@ private fun GenericCommentPostBody(
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ContentSensitivityExplainer(
description = postViewModel.contentWarningDescription,
@@ -267,7 +266,7 @@ private fun GenericCommentPostBody(
if (postViewModel.wantsExpirationDate) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ExpirationDatePicker(postViewModel)
}
@@ -276,7 +275,7 @@ private fun GenericCommentPostBody(
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
LocationAsHash(postViewModel)
}
@@ -285,7 +284,7 @@ private fun GenericCommentPostBody(
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ForwardZapTo(postViewModel, accountViewModel)
}
@@ -294,7 +293,7 @@ private fun GenericCommentPostBody(
postViewModel.multiOrchestrator?.let {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
val context = LocalContext.current
ImageVideoDescription(
@@ -313,25 +312,30 @@ private fun GenericCommentPostBody(
if (postViewModel.wantsInvoice) {
postViewModel.lnAddress()?.let { lud16 ->
InvoiceRequest(
lud16,
accountViewModel.account.userProfile(),
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
InvoiceRequest(
lud16,
accountViewModel.account.userProfile(),
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
}
}
if (postViewModel.wantsSecretEmoji) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
Column(Modifier.fillMaxWidth()) {
SecretEmojiRequest {
@@ -345,7 +349,7 @@ private fun GenericCommentPostBody(
if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ZapRaiserRequest(
stringRes(id = R.string.zapraiser),
@@ -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,525 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton
import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ObserveInboxRelayListAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongFormPostScreen(
draft: Note? = null,
version: Note? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val postViewModel: LongFormPostViewModel = viewModel()
postViewModel.init(accountViewModel)
LaunchedEffect(postViewModel, accountViewModel) {
postViewModel.load(draft, version)
}
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
titleRes = R.string.new_long_form_post,
isActive = postViewModel::canPost,
onPost = {
accountViewModel.launchSigner {
postViewModel.sendPostSync()
nav.popBack()
}
},
onCancel = {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
},
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
MarkdownPostScreenBody(postViewModel, accountViewModel, nav)
}
}
}
@Composable
private fun MarkdownPostScreenBody(
postViewModel: LongFormPostViewModel,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val scrollState = rememberScrollState()
Column(
modifier =
Modifier.fillMaxSize(),
) {
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(
start = Size10dp,
end = Size10dp,
).weight(1f),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(scrollState, reverseScrolling = true),
) {
// Title field
OutlinedTextField(
value = postViewModel.title,
onValueChange = {
postViewModel.title = it
postViewModel.draftTag.newVersion()
},
label = { Text(stringRes(R.string.article_title)) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = Size10dp, vertical = Size5dp),
textStyle =
LocalTextStyle.current.copy(
fontSize = 20.sp,
textDirection = TextDirection.Content,
),
singleLine = true,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Words,
),
)
// Summary field
OutlinedTextField(
value = postViewModel.summary,
onValueChange = {
postViewModel.summary = it
postViewModel.draftTag.newVersion()
},
label = { Text(stringRes(R.string.article_summary)) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = Size10dp, vertical = Size5dp),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 3,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
// Cover image URL field
OutlinedTextField(
value = postViewModel.coverImageUrl,
onValueChange = {
postViewModel.coverImageUrl = it
postViewModel.draftTag.newVersion()
},
label = { Text(stringRes(R.string.article_cover_image_url)) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = Size10dp, vertical = Size5dp),
singleLine = true,
)
HorizontalDivider(modifier = Modifier.padding(vertical = Size5dp))
// Edit / Preview tabs
TabRow(
selectedTabIndex = if (postViewModel.showPreview) 1 else 0,
) {
Tab(
selected = !postViewModel.showPreview,
onClick = { postViewModel.showPreview = false },
text = { Text(stringRes(R.string.markdown_edit)) },
)
Tab(
selected = postViewModel.showPreview,
onClick = { postViewModel.showPreview = true },
text = { Text(stringRes(R.string.markdown_preview)) },
)
}
if (postViewModel.showPreview) {
// Markdown preview (scrollable)
val backgroundColor = remember { mutableStateOf(Color.Transparent) }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(Size10dp),
) {
RenderContentAsMarkdown(
content = postViewModel.message.text,
tags = EmptyTagList,
canPreview = true,
quotesLeft = 1,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
} else {
// Markdown editor
OutlinedTextField(
value = postViewModel.message,
onValueChange = postViewModel::updateMessage,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = Size10dp, vertical = Size5dp),
placeholder = {
Text(
text = stringRes(R.string.write_your_article_in_markdown),
color = MaterialTheme.colorScheme.placeholderText,
)
},
textStyle =
LocalTextStyle.current.copy(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
textDirection = TextDirection.Content,
),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
}
// Content warning section
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ContentSensitivityExplainer(
description = postViewModel.contentWarningDescription,
onDescriptionChange = { postViewModel.contentWarningDescription = it },
)
}
}
if (postViewModel.wantsExpirationDate) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ExpirationDatePicker(postViewModel)
}
}
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
LocationAsHash(postViewModel) {
SettingsRow(
R.string.geohash_exclusive,
R.string.geohash_exclusive_explainer,
) {
Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it })
}
}
}
}
// Forward zap section
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ForwardZapTo(postViewModel, accountViewModel)
}
}
// Image upload section
postViewModel.multiOrchestrator?.let {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
val context = LocalContext.current
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
onCancel = { postViewModel.multiOrchestrator = null },
accountViewModel = accountViewModel,
)
}
}
if (postViewModel.wantsInvoice) {
postViewModel.lnAddress()?.let { lud16 ->
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
InvoiceRequest(
lud16,
accountViewModel.account.userProfile(),
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
}
}
if (postViewModel.wantsSecretEmoji) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
Column(Modifier.fillMaxWidth()) {
SecretEmojiRequest {
postViewModel.insertAtCursor(it)
postViewModel.wantsSecretEmoji = false
}
}
}
}
// Zap raiser section
if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ZapRaiserRequest(
stringRes(id = R.string.zapraiser),
postViewModel,
)
}
}
}
}
// User suggestions
postViewModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
postViewModel::autocompleteWithUser,
accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
// Emoji suggestions
postViewModel.emojiSuggestions?.let {
ShowEmojiSuggestionList(
it,
postViewModel::autocompleteWithEmoji,
postViewModel::autocompleteWithEmojiUrl,
modifier = SuggestionListDefaultHeightPage,
)
}
// Bottom action bar
MarkdownBottomRowActions(postViewModel)
}
}
@Composable
private fun MarkdownBottomRowActions(postViewModel: LongFormPostViewModel) {
val scrollState = rememberScrollState()
Row(
modifier =
Modifier
.horizontalScroll(scrollState)
.fillMaxWidth()
.height(50.dp),
verticalAlignment = CenterVertically,
) {
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
postViewModel.selectImage(it)
}
SelectFromFiles(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
postViewModel.selectImage(it)
}
TakePictureButton(
onPictureTaken = {
postViewModel.selectImage(it)
},
)
ForwardZapToButton(postViewModel.wantsForwardZapTo) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapRaiser) {
postViewModel.wantsZapRaiser = !postViewModel.wantsZapRaiser
}
}
MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) {
postViewModel.toggleMarkAsSensitive()
}
ExpirationDateButton(postViewModel.wantsExpirationDate) {
postViewModel.toggleExpirationDate()
}
AddGeoHashButton(postViewModel.wantsToAddGeoHash) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
AddSecretEmojiButton(postViewModel.wantsSecretEmoji) {
postViewModel.wantsSecretEmoji = !postViewModel.wantsSecretEmoji
}
if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
}
}
@@ -18,11 +18,12 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
@@ -35,8 +36,10 @@ import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
@@ -46,6 +49,8 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser
@@ -54,23 +59,33 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.alt
@@ -83,18 +98,23 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
@Stable
class MarkdownPostViewModel :
class LongFormPostViewModel :
ViewModel(),
ILocationGrabber,
IMessageField,
IZapField,
IZapRaiser {
IZapRaiser,
IExpiration {
val draftTag = DraftTagState()
lateinit var accountViewModel: AccountViewModel
@@ -103,6 +123,7 @@ class MarkdownPostViewModel :
init {
viewModelScope.launch(Dispatchers.IO) {
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
accountViewModel.launchSigner {
sendDraftSync()
@@ -115,6 +136,7 @@ class MarkdownPostViewModel :
var title by mutableStateOf(TextFieldValue(""))
var summary by mutableStateOf(TextFieldValue(""))
var coverImageUrl by mutableStateOf("")
var publishedAt by mutableLongStateOf(TimeUtils.now())
override var message by mutableStateOf(TextFieldValue(""))
@@ -127,22 +149,42 @@ class MarkdownPostViewModel :
var userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
var emojiSuggestions: EmojiSuggestionState? = null
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
var contentWarningDescription by mutableStateOf("")
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
var wantsSecretEmoji by mutableStateOf(false)
// Forward Zap to
var wantsForwardZapTo by mutableStateOf(false)
override var forwardZapTo = mutableStateOf<SplitBuilder<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
@@ -150,7 +192,8 @@ class MarkdownPostViewModel :
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
this.canAddZapRaiser = accountVM.userProfile().lnAddress() != null
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client)
@@ -163,18 +206,91 @@ class MarkdownPostViewModel :
draft: Note?,
version: Note?,
) {
val noteEvent = version?.event ?: draft?.event
val noteEvent = draft?.event
val noteAuthor = draft?.author
if (noteEvent is LongTextNoteEvent) {
title = TextFieldValue(noteEvent.title() ?: "")
summary = TextFieldValue(noteEvent.summary() ?: "")
coverImageUrl = noteEvent.image() ?: ""
message = TextFieldValue(noteEvent.content)
existingDTag = noteEvent.dTag()
editingNote = version ?: draft
if (draft != null && noteEvent is DraftWrapEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
}
loadFromDraft(innerNote)
}
}
} else {
val noteEvent = version?.event
if (noteEvent is LongTextNoteEvent) {
title = TextFieldValue(noteEvent.title() ?: "")
summary = TextFieldValue(noteEvent.summary() ?: "")
publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt
coverImageUrl = noteEvent.image() ?: ""
message = TextFieldValue(noteEvent.content)
existingDTag = noteEvent.dTag()
editingNote = version
}
val user = account.userProfile()
canAddInvoice = user.lnAddress() != null
canAddZapRaiser = user.lnAddress() != null
multiOrchestrator = null
}
}
private fun loadFromDraft(draft: Note) {
val draftEvent = draft.event ?: return
if (draftEvent is LongTextNoteEvent) {
loadFromDraft(draftEvent)
}
}
private fun loadFromDraft(draftEvent: LongTextNoteEvent) {
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null
multiOrchestrator = null
val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" }
forwardZapTo.value = SplitBuilder()
localForwardZapTo.forEach {
val user = LocalCache.getOrCreateUser(it[1])
val value = it.last().toFloatOrNull() ?: 0f
forwardZapTo.value.addItem(user, value)
}
forwardZapToEditting.value = TextFieldValue("")
wantsForwardZapTo = localForwardZapTo.isNotEmpty()
wantsToMarkAsSensitive = draftEvent.isSensitive()
contentWarningDescription = draftEvent.contentWarningReason() ?: ""
val draftExpiration = draftEvent.tags.expiration()
wantsExpirationDate = draftExpiration != null
expirationDate = draftExpiration ?: TimeUtils.oneDayAhead()
val geohash = draftEvent.getGeoHash()
wantsToAddGeoHash = geohash != null
if (geohash != null) {
wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND
}
val zapRaiser = draftEvent.zapraiserAmount()
wantsZapRaiser = zapRaiser != null
zapRaiserAmount.value = null
if (zapRaiser != null) {
zapRaiserAmount.value = zapRaiser
}
if (forwardZapTo.value.items.isNotEmpty()) {
wantsForwardZapTo = true
}
message = TextFieldValue(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
}
suspend fun sendPostSync() {
val template = createTemplate() ?: return
@@ -209,7 +325,7 @@ class MarkdownPostViewModel :
}
}
@OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@OptIn(ExperimentalUuidApi::class)
private suspend fun createTemplate(): EventTemplate<out Event>? {
if (title.text.isBlank()) return null
@@ -223,28 +339,34 @@ class MarkdownPostViewModel :
tagger.run()
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
val urls = findURLs(tagger.message)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
return LongTextNoteEvent.build(
description = tagger.message,
title = title.text.trim(),
summary = summary.text.trim().ifBlank { null },
image = coverImageUrl.trim().ifBlank { null },
publishedAt = TimeUtils.now(),
dTag = existingDTag ?: kotlin.uuid.Uuid.random().toString(),
publishedAt = publishedAt,
dTag = existingDTag ?: RandomInstance.randomChars(16),
) {
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
@@ -311,9 +433,12 @@ class MarkdownPostViewModel :
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType?.let { mimeType(it) }
state.result.fileHeader.dim?.let { dims(it) }
state.result.fileHeader.blurHash?.let { blurhash(it.blurhash) }
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
@@ -344,6 +469,7 @@ class MarkdownPostViewModel :
summary = TextFieldValue("")
coverImageUrl = ""
message = TextFieldValue("")
publishedAt = TimeUtils.now()
showPreview = false
editingNote = null
@@ -352,11 +478,16 @@ class MarkdownPostViewModel :
multiOrchestrator = null
isUploadingImage = false
wantsInvoice = false
wantsZapRaiser = false
zapRaiserAmount.value = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
contentWarningDescription = ""
wantsZapRaiser = false
zapRaiserAmount.value = null
wantsToAddGeoHash = false
wantsExclusiveGeoPost = false
wantsSecretEmoji = false
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("")
@@ -442,6 +573,7 @@ class MarkdownPostViewModel :
title.text.isNotBlank() &&
message.text.isNotBlank() &&
!isUploadingImage &&
!wantsInvoice &&
(!wantsZapRaiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
@@ -453,5 +585,56 @@ class MarkdownPostViewModel :
multiOrchestrator = MultiOrchestrator(uris)
}
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
override fun locationFlow(): StateFlow<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
}
@@ -223,7 +223,7 @@ private fun NewProductBody(
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ContentSensitivityExplainer(
description = postViewModel.contentWarningDescription,
@@ -235,7 +235,7 @@ private fun NewProductBody(
if (postViewModel.wantsExpirationDate) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ExpirationDatePicker(postViewModel)
}
@@ -244,7 +244,7 @@ private fun NewProductBody(
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
LocationAsHash(postViewModel)
}
@@ -253,7 +253,7 @@ private fun NewProductBody(
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ForwardZapTo(postViewModel, accountViewModel)
}
@@ -262,7 +262,7 @@ private fun NewProductBody(
postViewModel.multiOrchestrator?.let {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
val context = LocalContext.current
@@ -282,25 +282,30 @@ private fun NewProductBody(
if (postViewModel.wantsInvoice) {
postViewModel.lnAddress()?.let { lud16 ->
InvoiceRequest(
lud16,
accountViewModel.account.userProfile(),
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
InvoiceRequest(
lud16,
accountViewModel.account.userProfile(),
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
}
}
if (postViewModel.wantsSecretEmoji) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
Column(Modifier.fillMaxWidth()) {
SecretEmojiRequest {
@@ -314,7 +319,7 @@ private fun NewProductBody(
if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ZapRaiserRequest(
stringRes(id = R.string.zapraiser),
@@ -1,416 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MarkdownPostScreen(
draft: Note? = null,
version: Note? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val postViewModel: MarkdownPostViewModel = viewModel()
postViewModel.init(accountViewModel)
LaunchedEffect(postViewModel, accountViewModel) {
postViewModel.load(draft, version)
}
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
titleRes = R.string.new_long_form_post,
isActive = postViewModel::canPost,
onPost = {
accountViewModel.launchSigner {
postViewModel.sendPostSync()
nav.popBack()
}
},
onCancel = {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
},
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
MarkdownPostScreenBody(postViewModel, accountViewModel, nav)
}
}
}
@Composable
private fun MarkdownPostScreenBody(
postViewModel: MarkdownPostViewModel,
accountViewModel: AccountViewModel,
nav: Nav,
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
// Title field
OutlinedTextField(
value = postViewModel.title,
onValueChange = {
postViewModel.title = it
postViewModel.draftTag.newVersion()
},
label = { Text(stringRes(R.string.article_title)) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = Size10dp, vertical = Size5dp),
textStyle =
LocalTextStyle.current.copy(
fontSize = 20.sp,
textDirection = TextDirection.Content,
),
singleLine = true,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Words,
),
)
// Summary field
OutlinedTextField(
value = postViewModel.summary,
onValueChange = {
postViewModel.summary = it
postViewModel.draftTag.newVersion()
},
label = { Text(stringRes(R.string.article_summary)) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = Size10dp, vertical = Size5dp),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 3,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
// Cover image URL field
OutlinedTextField(
value = postViewModel.coverImageUrl,
onValueChange = {
postViewModel.coverImageUrl = it
postViewModel.draftTag.newVersion()
},
label = { Text(stringRes(R.string.article_cover_image_url)) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = Size10dp, vertical = Size5dp),
singleLine = true,
)
HorizontalDivider(modifier = Modifier.padding(vertical = Size5dp))
// Edit / Preview tabs
TabRow(
selectedTabIndex = if (postViewModel.showPreview) 1 else 0,
) {
Tab(
selected = !postViewModel.showPreview,
onClick = { postViewModel.showPreview = false },
text = { Text(stringRes(R.string.markdown_edit)) },
)
Tab(
selected = postViewModel.showPreview,
onClick = { postViewModel.showPreview = true },
text = { Text(stringRes(R.string.markdown_preview)) },
)
}
if (postViewModel.showPreview) {
// Markdown preview (scrollable)
val previewScrollState = rememberScrollState()
val backgroundColor = remember { mutableStateOf(Color.Transparent) }
Column(
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(previewScrollState)
.padding(Size10dp),
) {
RenderContentAsMarkdown(
content = postViewModel.message.text,
tags = EmptyTagList,
canPreview = true,
quotesLeft = 1,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
} else {
// Markdown editor
OutlinedTextField(
value = postViewModel.message,
onValueChange = postViewModel::updateMessage,
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = Size10dp, vertical = Size5dp),
placeholder = {
Text(
text = stringRes(R.string.write_your_article_in_markdown),
color = MaterialTheme.colorScheme.placeholderText,
)
},
textStyle =
LocalTextStyle.current.copy(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
textDirection = TextDirection.Content,
),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
}
// Content warning section
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
ContentSensitivityExplainer(
description = postViewModel.contentWarningDescription,
onDescriptionChange = { postViewModel.contentWarningDescription = it },
)
}
}
// Forward zap section
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp),
) {
ForwardZapTo(postViewModel, accountViewModel)
}
}
// Image upload section
postViewModel.multiOrchestrator?.let {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
val context = LocalContext.current
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
onCancel = { postViewModel.multiOrchestrator = null },
accountViewModel = accountViewModel,
)
}
}
// Zap raiser section
if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
ZapRaiserRequest(
stringRes(id = R.string.zapraiser),
postViewModel,
)
}
}
// User suggestions
postViewModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
postViewModel::autocompleteWithUser,
accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
// Emoji suggestions
postViewModel.emojiSuggestions?.let {
ShowEmojiSuggestionList(
it,
postViewModel::autocompleteWithEmoji,
postViewModel::autocompleteWithEmojiUrl,
modifier = SuggestionListDefaultHeightPage,
)
}
// Bottom action bar
MarkdownBottomRowActions(postViewModel)
}
}
@Composable
private fun MarkdownBottomRowActions(postViewModel: MarkdownPostViewModel) {
val scrollState = rememberScrollState()
Row(
modifier =
Modifier
.horizontalScroll(scrollState)
.fillMaxWidth()
.height(50.dp),
verticalAlignment = CenterVertically,
) {
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
postViewModel.selectImage(it)
}
SelectFromFiles(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
postViewModel.selectImage(it)
}
TakePictureButton(
onPictureTaken = {
postViewModel.selectImage(it)
},
)
ForwardZapToButton(postViewModel.wantsForwardZapTo) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapRaiser) {
postViewModel.wantsZapRaiser = !postViewModel.wantsZapRaiser
}
}
MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
}
}
@@ -104,7 +104,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@@ -293,7 +292,7 @@ private fun NewPostScreenBody(
if (postViewModel.wantsPoll) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
PollOptionsField(postViewModel)
}
@@ -304,7 +303,7 @@ private fun NewPostScreenBody(
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ContentSensitivityExplainer(
description = postViewModel.contentWarningDescription,
@@ -316,7 +315,7 @@ private fun NewPostScreenBody(
if (postViewModel.wantsExpirationDate) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ExpirationDatePicker(postViewModel)
}
@@ -325,7 +324,7 @@ private fun NewPostScreenBody(
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
LocationAsHash(postViewModel) {
SettingsRow(
@@ -341,7 +340,7 @@ private fun NewPostScreenBody(
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ForwardZapTo(postViewModel, accountViewModel)
}
@@ -350,7 +349,7 @@ private fun NewPostScreenBody(
postViewModel.multiOrchestrator?.let {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
val context = LocalContext.current
ImageVideoDescription(
@@ -378,7 +377,7 @@ private fun NewPostScreenBody(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = Size5dp, horizontal = Size10dp),
.padding(vertical = Size10dp, horizontal = Size10dp),
) {
// Display voice preview or uploading progress
postViewModel.voiceOrchestrator?.let { orchestrator ->
@@ -416,25 +415,30 @@ private fun NewPostScreenBody(
if (postViewModel.wantsInvoice) {
postViewModel.lnAddress()?.let { lud16 ->
InvoiceRequest(
lud16,
accountViewModel.account.userProfile(),
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
InvoiceRequest(
lud16,
accountViewModel.account.userProfile(),
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
}
}
if (postViewModel.wantsSecretEmoji) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
Column(Modifier.fillMaxWidth()) {
SecretEmojiRequest {
@@ -448,7 +452,7 @@ private fun NewPostScreenBody(
if (postViewModel.wantsZapRaiser && postViewModel.hasLnAddress()) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
ZapRaiserRequest(
stringRes(id = R.string.zapraiser),