- Separates new Product Screen from the generic new Post screen.

- Adds New product button on the Marketplace tab
- Adds imeta tags for images and urls inside the content of the Classifieds.
- Shows multiple images on the post and thread view.
- Removes the option for NIP-95 images on Classifieds.
- Creates a new route for new products
- Adjusts quarts to process images with iMeta tags
This commit is contained in:
Vitor Pamplona
2025-04-28 21:13:41 -04:00
parent c07592f399
commit 1c8a6d1234
21 changed files with 1448 additions and 306 deletions
@@ -579,6 +579,21 @@ class Account(
normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()),
)
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<String> {
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<LiveFollowList> =
userProfile().flow().follows.stateFlow.transformLatest {
@@ -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>(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
}
@@ -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<String>,
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,
@@ -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<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.NewProduct> {
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<Route.NewPost> {
NewPostScreen(
message = it.message,
@@ -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<Route.Nip47NWCSetup>() -> entry.toRoute<Route.Nip47NWCSetup>()
dest.hasRoute<Route.Room>() -> entry.toRoute<Route.Room>()
dest.hasRoute<Route.NewPost>() -> entry.toRoute<Route.NewPost>()
dest.hasRoute<Route.NewProduct>() -> entry.toRoute<Route.NewProduct>()
else -> {
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,
)
}
}
}
@@ -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)
}
@@ -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,
@@ -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
}
}
@@ -68,6 +68,10 @@ class IMetaAttachments {
}
}
fun add(imeta: IMetaTag) {
replace(imeta.url, imeta)
}
fun addAll(imetas: List<IMetaTag>) {
imetas.forEach {
replace(it.url, it)
@@ -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,
@@ -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
}
}
}
}
@@ -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<List<ProductImageMeta>>(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<MultiOrchestrator?>(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>(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<User>>(SplitBuilder())
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
var location: StateFlow<LocationState.LocationResult>? = null
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapraiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(null)
var relayList by mutableStateOf<ImmutableList<String>?>(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<out Event>? {
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<Account.EmojiMedia>?,
): List<EmojiUrlTag> {
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<String> = (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<SelectedMedia>) {
multiOrchestrator = MultiOrchestrator(uris)
}
override fun locationFlow(): StateFlow<LocationState.LocationResult> {
if (location == null) {
location = locationManager().geohashStateFlow
}
return location!!
}
override fun onCleared() {
super.onCleared()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
}
override fun updateZapPercentage(
index: Int,
sliderValue: Float,
) {
forwardZapTo.value.updatePercentage(index, sliderValue)
}
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.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
}
@@ -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))
@@ -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 {
+1
View File
@@ -964,6 +964,7 @@
<string name="new_post">New Post</string>
<string name="new_short">New Shorts: images or videos</string>
<string name="new_community_note">New Community Note</string>
<string name="new_product">New Product</string>
<string name="open_all_reactions_to_this_post">Open all reactions to this post</string>
<string name="close_all_reactions_to_this_post">Close all reactions to this post</string>
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.utils.ensure
class IMetaTag(
val url: String,
val properties: Map<String, List<String>>,
val properties: Map<String, List<String>> = emptyMap(),
) {
fun toTagArray() =
arrayOf(TAG_NAME, "$ANCHOR_PROPERTY $url") +
@@ -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<ProductImageMeta> {
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"
@@ -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<String> = 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(),
)
}
}
@@ -36,7 +36,7 @@ fun TagArrayBuilder<ClassifiedsEvent>.summary(summary: String) = addUnique(Summa
fun TagArrayBuilder<ClassifiedsEvent>.location(location: String) = addUnique(LocationTag.assemble(location))
fun TagArrayBuilder<ClassifiedsEvent>.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl))
fun TagArrayBuilder<ClassifiedsEvent>.image(imageUrl: String) = add(ImageTag.assemble(imageUrl))
fun TagArrayBuilder<ClassifiedsEvent>.images(imageUrls: List<String>) = addAll(imageUrls.map { ImageTag.assemble(it) })
@@ -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<String>): CONDITION? = parse(tag)?.let { CONDITION.parse(it) }
@JvmStatic
fun assemble(condition: CONDITION) = arrayOf(TAG_NAME, condition.code)
}
}