diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ec2ad0893..057a8c615 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { + LongFormPostScreen( + draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { ShortNotePostScreen( message = it.message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 29a7f30c8..765748f7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt index 77dbf33fc..c407871b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt @@ -59,7 +59,7 @@ fun ContentSensitivityExplainer( modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp), + .padding(bottom = 5.dp), ) { Box( Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt index 967f41058..d494117dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt @@ -99,7 +99,7 @@ fun ExpirationDatePicker(model: IExpiration) { modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp), + .padding(bottom = 5.dp), ) { Icon( imageVector = Icons.Outlined.Timer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt index 1577da5bf..e3f1568b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoiceRequest.kt @@ -107,7 +107,7 @@ fun InvoiceRequest( Column { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), ) { Icon( imageVector = CustomHashTagIcons.Lightning, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt index 4b2317218..86289842f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/DisplayLocationObserver.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -76,6 +77,8 @@ fun DisplayLocationInTitle(geohash: String) { text = cityName, fontSize = 20.sp, fontWeight = FontWeight.W500, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, modifier = Modifier.padding(start = Size5dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt index 0811fdbcd..6126fd837 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/secretEmoji/SecretEmojiRequest.kt @@ -59,13 +59,13 @@ fun SecretEmojiRequest(onSuccess: (String) -> Unit) { Column { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), ) { Icon( imageVector = Icons.Outlined.Assistant, null, modifier = Size20Modifier, - tint = Color.Unspecified, + tint = MaterialTheme.colorScheme.onBackground, ) Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt index 1f7fbc1f3..60cb55f3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/ZapRaiserRequest.kt @@ -54,7 +54,7 @@ fun ZapRaiserRequest( Column { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), ) { Icon( imageVector = CustomHashTagIcons.Lightning, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt index c1da5ee7b..c5d7e1564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt @@ -67,7 +67,7 @@ fun ForwardZapTo( modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp), + .padding(bottom = 5.dp), ) { ZapSplitIcon() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 1833637a0..aba7a0120 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index cb02b626c..3a59adf08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -252,8 +252,14 @@ private fun DiscoverPages( }, floatingButton = { val currentPage = pagerState.currentPage - if (currentPage >= 0 && currentPage < feedTabs.size && feedTabs[currentPage].resource == R.string.discover_marketplace) { - NewProductButton(accountViewModel, nav) + if (currentPage >= 0 && currentPage < feedTabs.size) { + if (feedTabs[currentPage].resource == R.string.discover_marketplace) { + NewProductButton(accountViewModel, nav) + } + + if (feedTabs[currentPage].resource == R.string.discover_reads) { + NewLongFormMarkdownButton(accountViewModel, nav) + } } }, accountViewModel = accountViewModel, @@ -357,6 +363,28 @@ fun NewProductButton( } } +@Composable +fun NewLongFormMarkdownButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.NewLongFormPost()) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringRes(id = R.string.new_long_form_post), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} + @Composable private fun RenderDiscoverFeed( feedContentState: FeedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt new file mode 100644 index 000000000..a56dd41af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -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 + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt new file mode 100644 index 000000000..a9da009eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -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(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()) + override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") + + // Expiration Date (NIP-40) + var wantsExpirationDate by mutableStateOf(false) + override var expirationDate by mutableLongStateOf(TimeUtils.oneDayAhead()) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + var wantsExclusiveGeoPost by mutableStateOf(false) + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapRaiser by mutableStateOf(false) + override val zapRaiserAmount = mutableStateOf(null) + + fun lnAddress(): String? = account.userProfile().lnAddress() + + fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null + + // Editing existing article + var editingNote: Note? by mutableStateOf(null) + var existingDTag: String? = null + + 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? { + 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?, + ): List { + 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) { + multiOrchestrator = MultiOrchestrator(uris) + } + + override fun locationFlow(): StateFlow { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + override fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.value.updatePercentage(index, sliderValue) + } + + override fun updateZapFromText() { + viewModelScope.launch(Dispatchers.IO) { + val tagger = + NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.value.items.any { it.key == taggedUser }) { + forwardZapTo.value.addItem(taggedUser) + } + } + } + } + + override fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount.value = newAmount + draftTag.newVersion() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + draftTag.newVersion() + } + + fun toggleExpirationDate() { + wantsExpirationDate = !wantsExpirationDate + if (wantsExpirationDate) { + expirationDate = TimeUtils.oneDayAhead() + } + draftTag.newVersion() + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index bd3d93c57..b148a0f17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 2e6c7a076..74d9f3434 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -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), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 78c36fdbb..4e1c1d20e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1292,6 +1292,14 @@ New Community Note New Product New Geo-Exclusive Post + New Article + + Title + Summary (optional) + Cover image URL (optional) + Write your article in markdown… + Preview + Edit Open all reactions to this post Close all reactions to this post