diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 5c203f20d..667e70a79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -579,6 +579,21 @@ class Account( normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()), ) + fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.writeRelays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() + } + + val normalizedNIP65WriteRelayList = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListWithBackup(it.note) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + normalizeNIP65WriteRelayListWithBackup(getNIP65RelayListNote()), + ) + @OptIn(ExperimentalCoroutinesApi::class) val liveKind3FollowsFlow: Flow = userProfile().flow().follows.stateFlow.transformLatest { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 6e2075a7a..cf6bc4664 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -37,7 +37,6 @@ 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.richtext.RichTextParser.Companion.imageExtensions import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache @@ -129,9 +128,6 @@ 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.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag -import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -198,14 +194,6 @@ open class NewPostViewModel : var isValidConsensusThreshold = mutableStateOf(true) var isValidClosedAt = mutableStateOf(true) - // Classifieds - var wantsProduct by mutableStateOf(false) - var title by mutableStateOf(TextFieldValue("")) - var price by mutableStateOf(TextFieldValue("")) - var locationText by mutableStateOf(TextFieldValue("")) - var category by mutableStateOf(TextFieldValue("")) - var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) - // Invoices var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) @@ -474,49 +462,6 @@ open class NewPostViewModel : } } - wantsProduct = draftEvent.kind == 30402 - - title = - TextFieldValue( - draftEvent - .tags - .filter { it.size > 1 && it[0] == "title" } - .map { it[1] } - ?.firstOrNull() ?: "", - ) - price = - TextFieldValue( - draftEvent - .tags - .filter { it.size > 1 && it[0] == "price" } - .map { it[1] } - ?.firstOrNull() ?: "", - ) - category = - TextFieldValue( - draftEvent - .tags - .filter { it.size > 1 && it[0] == "t" } - .map { it[1] } - ?.firstOrNull() ?: "", - ) - locationText = - TextFieldValue( - draftEvent - .tags - .filter { it.size > 1 && it[0] == "location" } - .map { it[1] } - ?.firstOrNull() ?: "", - ) - condition = ConditionTag.CONDITION.entries.firstOrNull { - it.value == - draftEvent - .tags - .filter { it.size > 1 && it[0] == "condition" } - .map { it[1] } - .firstOrNull() - } ?: ConditionTag.CONDITION.USED_LIKE_NEW - message = TextFieldValue(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) @@ -891,49 +836,6 @@ open class NewPostViewModel : imetas(usedAttachments) } - account?.signAndSend(localDraft, template, relayList, quotes) - } else if (wantsProduct) { - val images = - urls.mapNotNull { - val removedParamsFromUrl = - if (it.contains("?")) { - it.split("?")[0].lowercase() - } else if (it.contains("#")) { - it.split("#")[0].lowercase() - } else { - it - } - - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - it - } else { - null - } - } - - val quotes = findNostrUris(tagger.message) - - val template = - ClassifiedsEvent.build( - title.text, - PriceTag(price.text, "SATS", null), - tagger.message, - locationText.text.ifBlank { null }, - condition, - images, - ) { - hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message)) - quotes(quotes) - - geoHash?.let { geohash(it) } - localZapRaiserAmount?.let { zapraiser(it) } - zapReceiver?.let { zapSplits(it) } - contentWarningReason?.let { contentWarning(it) } - - emojis(emojis) - imetas(usedAttachments) - } - account?.signAndSend(localDraft, template, relayList, quotes) } else { val replyToSet = @@ -1078,13 +980,6 @@ open class NewPostViewModel : wantsZapraiser = false zapRaiserAmount.value = null - wantsProduct = false - condition = ConditionTag.CONDITION.USED_LIKE_NEW - locationText = TextFieldValue("") - title = TextFieldValue("") - category = TextFieldValue("") - price = TextFieldValue("") - wantsForwardZapTo = false wantsToMarkAsSensitive = false wantsToAddGeoHash = false @@ -1215,14 +1110,6 @@ open class NewPostViewModel : isValidvalueMaximum.value ) ) && - ( - !wantsProduct || - ( - !title.text.isNullOrBlank() && - !price.text.isNullOrBlank() && - !category.text.isNullOrBlank() - ) - ) && multiOrchestrator == null fun insertAtCursor(newElement: String) { @@ -1326,36 +1213,5 @@ open class NewPostViewModel : saveDraft() } - fun updateTitle(it: TextFieldValue) { - title = it - saveDraft() - } - - fun updatePrice(it: TextFieldValue) { - runCatching { - if (it.text.isEmpty()) { - price = TextFieldValue("") - } else if (it.text.toLongOrNull() != null) { - price = it - } - } - saveDraft() - } - - fun updateCondition(newCondition: ConditionTag.CONDITION) { - condition = newCondition - saveDraft() - } - - fun updateCategory(value: TextFieldValue) { - category = value - saveDraft() - } - - fun updateLocation(it: TextFieldValue) { - locationText = it - saveDraft() - } - override fun locationManager(): LocationState = Amethyst.instance.locationManager } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index f8914bb41..636e2fdbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -65,7 +65,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri -import coil3.compose.AsyncImage import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -342,43 +341,6 @@ private fun saveMediaToGallery( } } -@Composable -fun InlineCarrousel( - allImages: ImmutableList, - imageUrl: String, -) { - val pagerState: PagerState = rememberPagerState { allImages.size } - - LaunchedEffect(key1 = pagerState, key2 = imageUrl) { - launch { - val page = allImages.indexOf(imageUrl) - if (page > -1) { - pagerState.scrollToPage(page) - } - } - } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - ) { index -> - AsyncImage( - model = allImages[index], - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - } else { - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } -} - @Composable private fun RenderImageOrVideo( content: BaseMediaContent, 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 f63c22c5e..3744b0af2 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 @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen 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.marketplace.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen @@ -134,6 +135,17 @@ fun AppNavigation( composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { + NewProductScreen( + message = it.message, + attachment = it.attachment?.ifBlank { null }?.toUri(), + quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + accountViewModel, + nav, + ) + } + composableFromBottomArgs { NewPostScreen( message = it.message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 944af3e7f..b3869c95d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -124,6 +124,14 @@ sealed class Route { val id: String, ) : Route() + @Serializable + data class NewProduct( + val message: String? = null, + val attachment: String? = null, + val quote: String? = null, + val draft: String? = null, + ) : Route() + @Serializable data class NewPost( val message: String? = null, @@ -173,6 +181,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() else -> { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/AddClassifiedsButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/AddClassifiedsButton.kt deleted file mode 100644 index 72f7ad965..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/AddClassifiedsButton.kt +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright (c) 2024 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.note.creators.products - -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Sell -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange - -@Composable -fun AddClassifiedsButton( - isActive: Boolean, - onClick: () -> Unit, -) { - IconButton( - onClick = { onClick() }, - ) { - if (!isActive) { - Icon( - imageVector = Icons.Default.Sell, - contentDescription = stringRes(R.string.classifieds), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } else { - Icon( - imageVector = Icons.Default.Sell, - contentDescription = stringRes(id = R.string.cancel_classifieds), - modifier = Modifier.size(20.dp), - tint = BitcoinOrange, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index fa382cebc..a13822f52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -82,17 +82,34 @@ fun ImageVideoDescription( onDelete: (SelectedMediaProcessing) -> Unit, onCancel: () -> Unit, accountViewModel: AccountViewModel, +) { + ImageVideoDescription(uris, defaultServer, true, onAdd, onDelete, onCancel, accountViewModel) +} + +@Composable +fun ImageVideoDescription( + uris: MultiOrchestrator, + defaultServer: ServerName, + includeNIP95: Boolean, + onAdd: (String, ServerName, Boolean, Int) -> Unit, + onDelete: (SelectedMediaProcessing) -> Unit, + onCancel: () -> Unit, + accountViewModel: AccountViewModel, ) { val nip95description = stringRes(id = R.string.upload_server_relays_nip95) val fileServers by accountViewModel.account.liveServerList.collectAsState() val fileServerOptions = - remember(fileServers) { + remember(fileServers, includeNIP95) { fileServers - .map { + .mapNotNull { if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) + if (includeNIP95) { + TitleExplainer(it.name, nip95description) + } else { + null + } } else { TitleExplainer(it.name, it.baseUrl) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt index b6b5e97c7..4e419bb82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import android.R.attr.maxLines import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -38,19 +38,20 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import kotlinx.collections.immutable.toImmutableList @Composable fun RenderClassifieds( @@ -59,12 +60,22 @@ fun RenderClassifieds( accountViewModel: AccountViewModel, nav: INav, ) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } - val price = remember(noteEvent) { noteEvent.price() } - val location = remember(noteEvent) { noteEvent.location() } + val imageSet = + noteEvent.imageMetas().ifEmpty { null }?.map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = note.toNostrUri(), + mimeType = it.mimeType, + ) + } + val title = noteEvent.title() + val summary = noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } + val price = noteEvent.price() + val location = noteEvent.location() Row( modifier = @@ -78,17 +89,16 @@ fun RenderClassifieds( ) { Column { Row { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringRes( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) + imageSet?.let { images -> + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images.toImmutableList(), + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } ?: run { DefaultImageHeader(note, accountViewModel) } @@ -100,7 +110,7 @@ fun RenderClassifieds( ) { title?.let { Text( - text = it, + text = "test'", style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index b601c3101..59b4c4733 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -108,8 +108,6 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrlFillWidth -import com.vitorpamplona.amethyst.ui.note.creators.products.AddClassifiedsButton -import com.vitorpamplona.amethyst.ui.note.creators.products.SellProduct 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 @@ -336,15 +334,6 @@ fun NewPostScreen( } } - if (postViewModel.wantsProduct) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), - ) { - SellProduct(postViewModel = postViewModel) - } - } - Row( modifier = Modifier.padding(vertical = Size10dp), ) { @@ -354,7 +343,7 @@ fun NewPostScreen( accountViewModel = accountViewModel, ) MessageField( - if (postViewModel.wantsProduct) R.string.description else R.string.what_s_on_your_mind, + R.string.what_s_on_your_mind, postViewModel, ) } @@ -526,16 +515,6 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { // postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) AddPollButton(postViewModel.wantsPoll) { postViewModel.wantsPoll = !postViewModel.wantsPoll - if (postViewModel.wantsPoll) { - postViewModel.wantsProduct = false - } - } - } - - AddClassifiedsButton(postViewModel.wantsProduct) { - postViewModel.wantsProduct = !postViewModel.wantsProduct - if (postViewModel.wantsProduct) { - postViewModel.wantsPoll = false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt index 65a8e2eb1..e19b576c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt @@ -68,6 +68,10 @@ class IMetaAttachments { } } + fun add(imeta: IMetaTag) { + replace(imeta.url, imeta) + } + fun addAll(imetas: List) { imetas.forEach { replace(it.url, it) 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 9ff8f6c37..6f6a77990 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 @@ -25,6 +25,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.GridCells @@ -34,7 +35,12 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab @@ -75,6 +81,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.TabItem import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -226,6 +234,11 @@ private fun DiscoverPages( } } }, + floatingButton = { + if (tabs[pagerState.currentPage].resource == R.string.discover_marketplace) { + NewProductButton(accountViewModel, nav) + } + }, accountViewModel = accountViewModel, ) { HorizontalPager(state = pagerState, contentPadding = it) { page -> @@ -299,6 +312,28 @@ private fun RenderDiscoverFeed( } } +@Composable +fun NewProductButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.NewProduct()) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringRes(id = R.string.new_product), + 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/marketplace/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/NewProductScreen.kt new file mode 100644 index 000000000..61166421c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/NewProductScreen.kt @@ -0,0 +1,487 @@ +/** + * Copyright (c) 2024 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.marketplace + +import android.net.Uri +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Nav +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +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.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.messagefield.MessageField +import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl +import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrlFillWidth +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.CloseButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisplayPreviews +import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.FillWidthQuoteBorderModifier +import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Height100Modifier +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.SquaredQuoteBorderModifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun NewProductScreen( + message: String? = null, + attachment: Uri? = null, + quote: Note? = null, + draft: Note? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: NewProductViewModel = viewModel() + postViewModel.init(accountViewModel) + + val context = LocalContext.current + + LaunchedEffect(Unit) { + postViewModel.reloadRelaySet() + draft?.let { + postViewModel.editFromDraft(it) + } + quote?.let { + postViewModel.quote(it) + } + message?.ifBlank { null }?.let { + postViewModel.updateMessage(TextFieldValue(it)) + } + attachment?.let { + withContext(Dispatchers.IO) { + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) + } + } + } + + NewProductScreen( + postViewModel, + accountViewModel, + nav, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun NewProductScreen( + postViewModel: NewProductViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchAndLoadMyEmojiList(accountViewModel) + + val scope = rememberCoroutineScope() + var showRelaysDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = CenterVertically, + ) { + Spacer(modifier = StdHorzSpacer) + + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showRelaysDialog = true }, + ) { + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = stringRes(id = R.string.relay_list_selector), + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + PostButton( + onPost = { + postViewModel.sendPost() + scope.launch { + delay(100) + nav.popBack() + } + }, + isActive = postViewModel.canPost(), + ) + } + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + scope.launch { + withContext(Dispatchers.IO) { + postViewModel.sendDraftSync() + postViewModel.cancel() + } + } + nav.popBack() + }, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + if (showRelaysDialog) { + RelaySelectionDialogEasy( + preSelectedList = postViewModel.relayList ?: persistentListOf(), + onClose = { showRelaysDialog = false }, + onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + NewProductBody( + postViewModel, + accountViewModel, + nav, + ) + } + } +} + +@Composable +private fun NewProductBody( + postViewModel: NewProductViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scrollState = rememberScrollState() + + Column(Modifier.fillMaxSize()) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp) + .weight(1f) + .verticalScroll(scrollState), + ) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + SellProduct(postViewModel = postViewModel) + } + + Row( + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + MessageField(R.string.description, postViewModel) + } + + DisplayPreviews(postViewModel, accountViewModel, nav) + + if (postViewModel.wantsToMarkAsSensitive) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ContentSensitivityExplainer() + } + } + + if (postViewModel.wantsToAddGeoHash) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + LocationAsHash(postViewModel) + } + } + + if (postViewModel.wantsForwardZapTo) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(top = Size5dp, bottom = Size5dp, start = Size10dp), + ) { + ForwardZapTo(postViewModel, accountViewModel) + } + } + + postViewModel.multiOrchestrator?.let { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + val context = LocalContext.current + + ImageVideoDescription( + uris = it, + defaultServer = accountViewModel.account.settings.defaultFileServer, + includeNIP95 = false, + onAdd = { alt, server, sensitiveContent, mediaQuality -> + postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + }, + onDelete = postViewModel::deleteMediaToUpload, + onCancel = { postViewModel.multiOrchestrator = null }, + accountViewModel = accountViewModel, + ) + } + } + + if (postViewModel.wantsInvoice) { + postViewModel.lnAddress()?.let { lud16 -> + InvoiceRequest( + lud16, + accountViewModel.account.userProfile().pubkeyHex, + accountViewModel, + stringRes(id = R.string.lightning_invoice), + stringRes(id = R.string.lightning_create_and_add_invoice), + onSuccess = { + 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), + ) { + Column(Modifier.fillMaxWidth()) { + SecretEmojiRequest { + postViewModel.insertAtCursor(it) + postViewModel.wantsSecretEmoji = false + } + } + } + } + + if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ZapRaiserRequest( + stringRes(id = R.string.zapraiser), + postViewModel, + ) + } + } + } + + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + } + + postViewModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + postViewModel::autocompleteWithEmoji, + postViewModel::autocompleteWithEmojiUrl, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + } + + BottomRowActions(postViewModel) + } +} + +@Composable +fun DisplayPreviews( + postViewModel: NewProductViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val urlPreviews by postViewModel.urlPreviews.results.collectAsStateWithLifecycle(emptyList()) + + if (urlPreviews.isNotEmpty()) { + Row(HalfHorzPadding) { + if (urlPreviews.size > 1) { + LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) { + items(urlPreviews) { + Box(SquaredQuoteBorderModifier) { + PreviewUrl(it, accountViewModel, nav) + } + } + } + } else { + Box(FillWidthQuoteBorderModifier) { + PreviewUrlFillWidth(urlPreviews[0], accountViewModel, nav) + } + } + } + } +} + +@Composable +private fun BottomRowActions(postViewModel: NewProductViewModel) { + 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) + } + + 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() + } + + 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/marketplace/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/NewProductViewModel.kt new file mode 100644 index 000000000..a537379de --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/NewProductViewModel.kt @@ -0,0 +1,667 @@ +/** + * Copyright (c) 2024 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.marketplace + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.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 +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +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.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.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +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.imetas +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nip99Classifieds.ProductImageMeta +import com.vitorpamplona.quartz.nip99Classifieds.image +import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag +import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@Stable +open class NewProductViewModel : + ViewModel(), + ILocationGrabber, + IMessageField, + IZapField, + IZapRaiser { + val draftTag = DraftTagState() + + init { + viewModelScope.launch(Dispatchers.IO) { + draftTag.versions.collectLatest { + sendDraft() + } + } + } + + var accountViewModel: AccountViewModel? = null + var account: Account? = null + + var productImages by mutableStateOf>(emptyList()) + val iMetaDescription = IMetaAttachments() + + override var message by mutableStateOf(TextFieldValue("")) + + val urlPreviews = PreviewState() + + var isUploadingImage by mutableStateOf(false) + + var userSuggestions: UserSuggestionState? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + var emojiSuggestions: EmojiSuggestionState? = null + + // Images and Videos + var multiOrchestrator by mutableStateOf(null) + + // Classifieds + var title by mutableStateOf(TextFieldValue("")) + var price by mutableStateOf(TextFieldValue("")) + var locationText by mutableStateOf(TextFieldValue("")) + var category by mutableStateOf(TextFieldValue("")) + var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) + + // 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) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + override val zapRaiserAmount = mutableStateOf(null) + + var relayList by mutableStateOf?>(null) + + fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + + fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null + + fun user(): User? = account?.userProfile() + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountVM) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM) + } + + fun editFromDraft(draft: Note) { + val accountViewModel = accountViewModel ?: return + + val noteEvent = draft.event + val noteAuthor = draft.author + + if (noteEvent is DraftEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel.createTempDraftNote(noteEvent) { innerNote -> + if (innerNote != null) { + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) + } + loadFromDraft(innerNote) + } + } + } + } + } + + open fun quote(quote: Note) { + val accountViewModel = accountViewModel ?: return + + message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + + quote.author?.let { quotedUser -> + if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { + forwardZapTo.value.addItem(quotedUser) + } + if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.value.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } + forwardZapTo.value.updatePercentage(pos, 0.9f) + } + } + + if (!forwardZapTo.value.items.isEmpty()) { + wantsForwardZapTo = true + } + + urlPreviews.update(message) + } + + private fun loadFromDraft(draft: Note) { + val draftEvent = draft.event ?: return + if (draftEvent !is ClassifiedsEvent) return + + loadFromDraft(draftEvent) + } + + private fun loadFromDraft(draftEvent: ClassifiedsEvent) { + val localfowardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + forwardZapTo.value = SplitBuilder() + localfowardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.value.addItem(user, value) + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localfowardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount.value = null + if (zapraiser != null) { + zapRaiserAmount.value = zapraiser + } + + title = TextFieldValue(draftEvent.title() ?: "") + price = TextFieldValue(draftEvent.price()?.amount ?: "") + category = TextFieldValue(draftEvent.categories().firstOrNull() ?: "") + locationText = TextFieldValue(draftEvent.location() ?: "") + condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW + + val imageSet = draftEvent.images().toMutableSet() + + draftEvent.imetas().forEach { + if (it.url in imageSet) { + productImages = productImages + ProductImageMeta.parse(it) + imageSet.remove(it.url) + } else { + iMetaDescription.add(it) + } + } + + imageSet.forEach { + productImages = productImages + ProductImageMeta(it) + } + + message = TextFieldValue(draftEvent.content) + + urlPreviews.update(message) + } + + fun sendPost() { + viewModelScope.launch(Dispatchers.IO) { + sendPostSync() + } + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + + accountViewModel?.account?.signAndSendPrivatelyOrBroadcast( + template, + relayList = { relayList }, + ) + + accountViewModel?.deleteDraft(draftTag.current) + + cancel() + } + + fun sendDraft() { + viewModelScope.launch(Dispatchers.IO) { + sendDraftSync() + } + } + + suspend fun sendDraftSync() { + val accountViewModel = accountViewModel ?: return + + if (message.text.isBlank()) { + accountViewModel.account.deleteDraft(draftTag.current) + } else { + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraft(draftTag.current, template) + } + } + + private suspend fun createTemplate(): EventTemplate? { + val accountViewModel = accountViewModel ?: return null + + val tagger = + NewMessageTagger( + message = message.text, + dao = accountViewModel, + ) + tagger.run() + + val emojis = findEmoji(tagger.message, account?.myEmojis?.value) + val urls = findURLs(tagger.message) + val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } + + val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + + val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null + val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null + val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + + val quotes = findNostrUris(tagger.message) + + val template = + ClassifiedsEvent.build( + title.text, + PriceTag(price.text, "SATS", null), + tagger.message, + locationText.text.ifBlank { null }, + condition, + ) { + productImages.forEach { image(it.url) } + + hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message)) + quotes(quotes) + + geoHash?.let { geohash(it) } + localZapRaiserAmount?.let { zapraiser(it) } + zapReceiver?.let { zapSplits(it) } + contentWarningReason?.let { contentWarning(it) } + + emojis(emojis) + imetas(usedAttachments) + references(urls) + } + + return template + } + + 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.url.url) } + } + } + + fun upload( + alt: String?, + contentWarningReason: String?, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + viewModelScope.launch(Dispatchers.Default) { + val myAccount = account ?: return@launch + + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + viewModelScope, + alt, + contentWarningReason, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + myAccount, + context, + ) + + if (results.allGood) { + results.successful.forEach { + if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + if (it.result.fileHeader.mimeType + ?.startsWith("image") == true + ) { + productImages = productImages + + ProductImageMeta( + it.result.url, + it.result.fileHeader.mimeType, + it.result.fileHeader.blurHash + ?.blurhash, + it.result.fileHeader.dim, + alt, + it.result.fileHeader.hash, + it.result.fileHeader.size, + ) + } else { + iMetaDescription.add(it.result, alt, contentWarningReason) + + message = message.insertUrlAtCursor(it.result.url) + urlPreviews.update(message) + } + } + } + + 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 + } + } + + open fun cancel() { + message = TextFieldValue("") + + multiOrchestrator = null + isUploadingImage = false + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount.value = null + + condition = ConditionTag.CONDITION.USED_LIKE_NEW + locationText = TextFieldValue("") + title = TextFieldValue("") + category = TextFieldValue("") + price = TextFieldValue("") + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsSecretEmoji = false + + forwardZapTo.value = SplitBuilder() + forwardZapToEditting.value = TextFieldValue("") + + urlPreviews.reset() + + userSuggestions?.reset() + userSuggestionsMainMessage = null + + productImages = emptyList() + iMetaDescription.reset() + + emojiSuggestions?.reset() + + reloadRelaySet() + + draftTag.rotate() + } + + fun reloadRelaySet() { + val account = accountViewModel?.account ?: return + + val nip65 = account.normalizedNIP65WriteRelayList.value + val private = account.normalizedPrivateOutBoxRelaySet.value + val local = account.settings.localRelayServers + + relayList = + if (nip65.isEmpty()) { + account.activeWriteRelays().map { it.url }.toImmutableList() + } else { + val combined: Set = (nip65 + private + local) + combined.toImmutableList() + } + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + + override fun updateMessage(it: TextFieldValue) { + message = it + urlPreviews.update(message) + + if (message.selection.collapsed) { + val lastWord = message.currentWord() + + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + + 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) + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestions?.let { userSuggestions -> + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.value.addItem(item) + forwardZapToEditting.value = TextFieldValue("") + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + } + + draftTag.newVersion() + } + + open fun autocompleteWithEmoji(item: Account.EmojiMedia) { + val wordToInsert = ":${item.code}:" + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + open fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) { + val wordToInsert = item.url.url + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaDescription.downloadAndPrepare( + item.url.url, + { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload() ?: false) }, + ) + } + + message = message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + fun canPost(): Boolean = + message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount.value != null) && + title.text.isNotBlank() && + price.text.isNotBlank() && + category.text.isNotBlank() && + 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.Default) { + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, 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 updateTitle(it: TextFieldValue) { + title = it + draftTag.newVersion() + } + + fun updatePrice(it: TextFieldValue) { + runCatching { + if (it.text.isEmpty()) { + price = TextFieldValue("") + } else if (it.text.toLongOrNull() != null) { + price = it + } + } + draftTag.newVersion() + } + + fun updateCondition(newCondition: ConditionTag.CONDITION) { + condition = newCondition + draftTag.newVersion() + } + + fun updateCategory(value: TextFieldValue) { + category = value + draftTag.newVersion() + } + + fun updateLocation(it: TextFieldValue) { + locationText = it + draftTag.newVersion() + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/SellProduct.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/SellProduct.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/SellProduct.kt index 35ff8316f..d8e3d2e13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/products/SellProduct.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/SellProduct.kt @@ -18,12 +18,18 @@ * 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.note.creators.products +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.marketplace +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -34,28 +40,62 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Height100Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SquaredQuoteBorderModifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag import kotlinx.collections.immutable.toImmutableList +@Preview @Composable -fun SellProduct(postViewModel: NewPostViewModel) { +fun SellProductPreview() { + val accountViewModel = mockAccountViewModel() + val postViewModel = NewProductViewModel() + postViewModel.init(accountViewModel) + + ThemeComparisonColumn { + SellProduct(postViewModel) + } +} + +@Composable +fun SellProduct(postViewModel: NewProductViewModel) { Column( modifier = Modifier.fillMaxWidth(), ) { + if (!postViewModel.productImages.isEmpty()) { + LazyRow(Height100Modifier, horizontalArrangement = spacedBy(Size5dp)) { + items(postViewModel.productImages) { + Box(SquaredQuoteBorderModifier) { + AsyncImage( + model = it.url, + contentDescription = it.alt ?: it.url, + contentScale = ContentScale.FillHeight, + modifier = Modifier.fillMaxHeight().aspectRatio(1f), + ) + } + } + } + } + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), @@ -233,9 +273,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() } TextSpinner( - placeholder = - categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second - ?: "", + placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "", options = categoryOptions, onSelect = { postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 660a5d6c7..81aa5d3c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -73,12 +73,14 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.components.InlineCarrousel import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.EmptyNav @@ -726,7 +728,19 @@ private fun RenderClassifiedsReaderForThread( accountViewModel: AccountViewModel, nav: INav, ) { - val images = remember(noteEvent) { noteEvent.images().toImmutableList() } + val imageSet = + noteEvent.imageMetas().ifEmpty { null }?.map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = note.toNostrUri(), + mimeType = it.mimeType, + ) + } + val title = remember(noteEvent) { noteEvent.title() } val summary = remember(noteEvent) { @@ -742,11 +756,14 @@ private fun RenderClassifiedsReaderForThread( Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { Column { - if (images.isNotEmpty()) { - Row { - InlineCarrousel( - images, - images.first(), + if (imageSet != null && imageSet.isNotEmpty()) { + AutoNonlazyGrid(imageSet.size) { + ZoomableContentView( + content = imageSet[it], + images = imageSet.toImmutableList(), + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, ) } } else { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7cd4e88bf..de69af3b0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -964,6 +964,7 @@ New Post New Shorts: images or videos New Community Note + New Product Open all reactions to this post Close all reactions to this post diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt index 1c7d89153..65534d5e0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTag.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.utils.ensure class IMetaTag( val url: String, - val properties: Map>, + val properties: Map> = emptyMap(), ) { fun toTagArray() = arrayOf(TAG_NAME, "$ANCHOR_PROPERTY $url") + diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt index 2e6239405..3b60cdd19 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag import com.vitorpamplona.quartz.nip99Classifieds.tags.LocationTag import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag @@ -55,6 +56,8 @@ class ClassifiedsEvent( fun condition() = tags.firstNotNullOfOrNull(ConditionTag::parse) + fun conditionValid() = tags.firstNotNullOfOrNull(ConditionTag::parseCondition) + fun images() = tags.mapNotNull(ImageTag::parse) fun status() = tags.firstNotNullOfOrNull(StatusTag::parse) @@ -71,6 +74,22 @@ class ClassifiedsEvent( fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS) + fun imageMetas(): List { + val images = images() + val imetas = imetas() + + val imetaSet = imetas.associate { it.url to it } + + return images.map { + val imeta = imetaSet.get(it) + if (imeta != null) { + ProductImageMeta.parse(imeta) + } else { + ProductImageMeta(it) + } + } + } + companion object { const val KIND = 30402 const val ALT_DESCRIPTION = "Classifieds listing" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt new file mode 100644 index 000000000..b3cc61c77 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2024 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.quartz.nip99Classifieds + +import com.vitorpamplona.quartz.nip68Picture.alt +import com.vitorpamplona.quartz.nip68Picture.blurhash +import com.vitorpamplona.quartz.nip68Picture.dims +import com.vitorpamplona.quartz.nip68Picture.hash +import com.vitorpamplona.quartz.nip68Picture.mimeType +import com.vitorpamplona.quartz.nip68Picture.size +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag + +data class ProductImageMeta( + val url: String, + val mimeType: String? = null, + val blurhash: String? = null, + val dimension: DimensionTag? = null, + val alt: String? = null, + val hash: String? = null, + val size: Int? = null, +) { + fun toIMeta(): IMetaTag = + IMetaTagBuilder(url) + .apply { + mimeType?.let { mimeType(it) } + alt?.let { alt(it) } + hash?.let { hash(it) } + size?.let { size(it) } + dimension?.let { dims(it) } + blurhash?.let { blurhash(it) } + }.build() + + fun toIMetaArray(): Array = toIMeta().toTagArray() + + companion object { + fun parse(iMeta: IMetaTag): ProductImageMeta = + ProductImageMeta( + iMeta.url, + iMeta.mimeType()?.firstOrNull(), + iMeta.blurhash()?.firstOrNull(), + iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + iMeta.alt()?.firstOrNull(), + iMeta.hash()?.firstOrNull(), + iMeta.size()?.firstOrNull()?.toIntOrNull(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt index 2da2c86da..7badec8b3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/TagArrayBuilderExt.kt @@ -36,7 +36,7 @@ fun TagArrayBuilder.summary(summary: String) = addUnique(Summa fun TagArrayBuilder.location(location: String) = addUnique(LocationTag.assemble(location)) -fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) +fun TagArrayBuilder.image(imageUrl: String) = add(ImageTag.assemble(imageUrl)) fun TagArrayBuilder.images(imageUrls: List) = addAll(imageUrls.map { ImageTag.assemble(it) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt index 2071cf817..490730eb3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/tags/ConditionTag.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.utils.ensure class ConditionTag { enum class CONDITION( - val value: String, + val code: String, ) { NEW("new"), USED_LIKE_NEW("like new"), @@ -34,6 +34,10 @@ class ConditionTag { ; fun toTagArray() = assemble(this) + + companion object { + fun parse(cond: String) = entries.firstOrNull { it.code == cond } + } } companion object { @@ -48,6 +52,9 @@ class ConditionTag { } @JvmStatic - fun assemble(condition: CONDITION) = arrayOf(TAG_NAME, condition.value) + fun parseCondition(tag: Array): CONDITION? = parse(tag)?.let { CONDITION.parse(it) } + + @JvmStatic + fun assemble(condition: CONDITION) = arrayOf(TAG_NAME, condition.code) } }