diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 103e00cbe..44ca2d9b0 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -3,30 +3,39 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 8d95c2f1f..620ba3974 100644
--- a/README.md
+++ b/README.md
@@ -78,7 +78,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Moderated Communities (NIP-72)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
-- [x] Classifieds (Kind:30403)
+- [x] Classifieds (Kind:30402)
- [x] Private Messages and Small Groups (NIP-24)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
index 614089478..d78715fd6 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -4,6 +4,8 @@ import android.content.res.Resources
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
+import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.Contact
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
@@ -55,6 +58,7 @@ import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP24Factory
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
+import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
@@ -91,6 +95,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import java.net.Proxy
import java.util.Locale
+import java.util.UUID
import kotlin.coroutines.resume
val DefaultChannels = setOf(
@@ -447,7 +452,7 @@ class Account(
val contactList = userProfile().latestContactList
if (contactList != null && contactList.tags.isNotEmpty()) {
- val event = ContactListEvent.updateRelayList(
+ ContactListEvent.updateRelayList(
earlierVersion = contactList,
relayUse = relays,
signer = signer
@@ -456,7 +461,7 @@ class Account(
LocalCache.justConsume(it, null)
}
} else {
- val event = ContactListEvent.createFromScratch(
+ ContactListEvent.createFromScratch(
followUsers = listOf(),
followTags = listOf(),
followGeohashes = listOf(),
@@ -1025,6 +1030,64 @@ class Account(
}
}
+ fun sendClassifieds(
+ title: String,
+ price: Price,
+ condition: ClassifiedsEvent.CONDITION,
+ location: String,
+ category: String,
+ message: String,
+ replyTo: List?,
+ mentions: List?,
+ directMentions: Set,
+ zapReceiver: List? = null,
+ wantsToMarkAsSensitive: Boolean,
+ zapRaiserAmount: Long? = null,
+ relayList: List? = null,
+ geohash: String? = null
+ ) {
+ if (!isWriteable()) return
+
+ val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
+ val mentionsHex = mentions?.map { it.pubkeyHex }
+ val addresses = replyTo?.mapNotNull { it.address() }
+
+ ClassifiedsEvent.create(
+ dTag = UUID.randomUUID().toString(),
+ title = title,
+ price = price,
+ condition = condition,
+ summary = message,
+ image = null,
+ location = location,
+ category = category,
+ message = message,
+ replyTos = repliesToHex,
+ mentions = mentionsHex,
+ addresses = addresses,
+ zapReceiver = zapReceiver,
+ markAsSensitive = wantsToMarkAsSensitive,
+ zapRaiserAmount = zapRaiserAmount,
+ directMentions = directMentions,
+ geohash = geohash,
+ signer = signer
+ ) {
+ Client.send(it, relayList = relayList)
+ LocalCache.justConsume(it, null)
+
+ replyTo?.forEach {
+ it.event?.let {
+ Client.send(it, relayList = relayList)
+ }
+ }
+ addresses?.forEach {
+ LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
+ Client.send(it, relayList = relayList)
+ }
+ }
+ }
+ }
+
fun sendPost(
message: String,
replyTo: List?,
@@ -1500,7 +1563,7 @@ class Account(
signer = signer
) {
Client.send(it)
- LocalCache.consume(it)
+ LocalCache.consume(it, null)
}
} else {
MuteListEvent.createListWithWord(
@@ -1509,7 +1572,7 @@ class Account(
signer = signer
) {
Client.send(it)
- LocalCache.consume(it)
+ LocalCache.consume(it, null)
}
}
}
@@ -1525,7 +1588,7 @@ class Account(
signer = signer
) {
Client.send(it)
- LocalCache.consume(it)
+ LocalCache.consume(it, null)
}
}
@@ -1539,7 +1602,7 @@ class Account(
signer = signer
) {
Client.send(it)
- LocalCache.consume(it)
+ LocalCache.consume(it, null)
}
}
}
@@ -1555,7 +1618,7 @@ class Account(
signer = signer
) {
Client.send(it)
- LocalCache.consume(it)
+ LocalCache.consume(it, null)
}
} else {
MuteListEvent.createListWithUser(
@@ -1564,7 +1627,7 @@ class Account(
signer = signer
) {
Client.send(it)
- LocalCache.consume(it)
+ LocalCache.consume(it, null)
}
}
}
@@ -1580,7 +1643,7 @@ class Account(
signer = signer
) {
Client.send(it)
- LocalCache.consume(it)
+ LocalCache.consume(it, null)
}
}
@@ -1594,7 +1657,7 @@ class Account(
signer = signer
) {
Client.send(it)
- LocalCache.consume(it)
+ LocalCache.consume(it, null)
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index 35651aba7..9f9c62aa5 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -276,7 +276,7 @@ object LocalCache {
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
- // Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
+ // Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
}
}
@@ -426,16 +426,16 @@ object LocalCache {
}
}
- fun consume(event: MuteListEvent) { consumeBaseReplaceable(event) }
- fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
- fun consume(event: EmojiPackSelectionEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: EmojiPackEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: ClassifiedsEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) }
+ fun consume(event: MuteListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ fun consume(event: PeopleListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: AdvertisedRelayListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ fun consume(event: EmojiPackSelectionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: EmojiPackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: ClassifiedsEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: PinListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: RelaySetEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: AudioTrackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: StatusEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -458,7 +458,7 @@ object LocalCache {
}
}
- fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
+ fun consume(event: BadgeDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: BadgeProfilesEvent) {
val version = getOrCreateNote(event.id)
@@ -504,14 +504,14 @@ object LocalCache {
refreshObservers(note)
}
- private fun comsume(event: NNSEvent) { consumeBaseReplaceable(event) }
- fun consume(event: AppDefinitionEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: CalendarEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: CalendarDateSlotEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: CalendarTimeSlotEvent) { consumeBaseReplaceable(event) }
- private fun consume(event: CalendarRSVPEvent) { consumeBaseReplaceable(event) }
+ private fun comsume(event: NNSEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ fun consume(event: AppDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: CalendarEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: CalendarDateSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: CalendarTimeSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
+ private fun consume(event: CalendarRSVPEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
- private fun consumeBaseReplaceable(event: BaseAddressableEvent) {
+ private fun consumeBaseReplaceable(event: BaseAddressableEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -521,6 +521,11 @@ object LocalCache {
version.moveAllReferencesTo(note)
}
+ if (relay != null) {
+ author.addRelayBeingUsed(relay, event.createdAt)
+ note.addRelay(relay)
+ }
+
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -531,7 +536,7 @@ object LocalCache {
}
}
- fun consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
+ fun consume(event: AppRecommendationEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
@@ -1511,26 +1516,26 @@ object LocalCache {
try {
when (event) {
- is AdvertisedRelayListEvent -> consume(event)
- is AppDefinitionEvent -> consume(event)
- is AppRecommendationEvent -> consume(event)
+ is AdvertisedRelayListEvent -> consume(event, relay)
+ is AppDefinitionEvent -> consume(event, relay)
+ is AppRecommendationEvent -> consume(event, relay)
is AudioHeaderEvent -> consume(event, relay)
- is AudioTrackEvent -> consume(event)
+ is AudioTrackEvent -> consume(event, relay)
is BadgeAwardEvent -> consume(event)
- is BadgeDefinitionEvent -> consume(event)
+ is BadgeDefinitionEvent -> consume(event, relay)
is BadgeProfilesEvent -> consume(event)
is BookmarkListEvent -> consume(event)
- is CalendarEvent -> consume(event)
- is CalendarDateSlotEvent -> consume(event)
- is CalendarTimeSlotEvent -> consume(event)
- is CalendarRSVPEvent -> consume(event)
+ is CalendarEvent -> consume(event, relay)
+ is CalendarDateSlotEvent -> consume(event, relay)
+ is CalendarTimeSlotEvent -> consume(event, relay)
+ is CalendarRSVPEvent -> consume(event, relay)
is ChannelCreateEvent -> consume(event)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ChatMessageEvent -> consume(event, relay)
- is ClassifiedsEvent -> consume(event)
+ is ClassifiedsEvent -> consume(event, relay)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let {
@@ -1540,8 +1545,8 @@ object LocalCache {
}
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
- is EmojiPackEvent -> consume(event)
- is EmojiPackSelectionEvent -> consume(event)
+ is EmojiPackEvent -> consume(event, relay)
+ is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
@@ -1563,15 +1568,15 @@ object LocalCache {
is LnZapPaymentResponseEvent -> consume(event)
is LongTextNoteEvent -> consume(event, relay)
is MetadataEvent -> consume(event)
- is MuteListEvent -> consume(event)
- is NNSEvent -> comsume(event)
+ is MuteListEvent -> consume(event, relay)
+ is NNSEvent -> comsume(event, relay)
is PrivateDmEvent -> consume(event, relay)
- is PinListEvent -> consume(event)
- is PeopleListEvent -> consume(event)
+ is PinListEvent -> consume(event, relay)
+ is PeopleListEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
- is RelaySetEvent -> consume(event)
+ is RelaySetEvent -> consume(event, relay)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
index ca6856d7c..ab08b95de 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt
@@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.LocationOff
import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.Sell
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -93,6 +94,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
@@ -152,6 +154,7 @@ import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
+import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -345,6 +348,15 @@ fun NewPostView(
}
}
+ if (postViewModel.wantsProduct) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
+ ) {
+ SellProduct(postViewModel = postViewModel)
+ }
+ }
+
MessageField(postViewModel)
if (postViewModel.wantsPoll) {
@@ -530,6 +542,16 @@ fun NewPostView(
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
+ if (postViewModel.wantsPoll) {
+ postViewModel.wantsProduct = false
+ }
+ }
+ }
+
+ AddClassifiedsButton(postViewModel) {
+ postViewModel.wantsProduct = !postViewModel.wantsProduct
+ if (postViewModel.wantsProduct) {
+ postViewModel.wantsPoll = false
}
}
@@ -635,7 +657,11 @@ private fun MessageField(
},
placeholder = {
Text(
- text = stringResource(R.string.what_s_on_your_mind),
+ text = if (postViewModel.wantsProduct) {
+ stringResource(R.string.description)
+ } else {
+ stringResource(R.string.what_s_on_your_mind)
+ },
color = MaterialTheme.colorScheme.placeholderText
)
},
@@ -776,6 +802,227 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
}
}
+@Composable
+fun SellProduct(postViewModel: NewPostViewModel) {
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.classifieds_title),
+ fontSize = Font14SP,
+ fontWeight = FontWeight.W500
+ )
+
+ MyTextField(
+ value = postViewModel.title,
+ onValueChange = {
+ postViewModel.title = it
+ },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = {
+ Text(
+ text = stringResource(R.string.classifieds_title_placeholder),
+ color = MaterialTheme.colorScheme.placeholderText
+ )
+ },
+ visualTransformation = UrlUserTagTransformation(
+ MaterialTheme.colorScheme.primary
+ ),
+ colors = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent
+ )
+ )
+ }
+
+ Divider()
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.classifieds_price),
+ fontSize = Font14SP,
+ fontWeight = FontWeight.W500
+ )
+
+ MyTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = postViewModel.price,
+ onValueChange = {
+ runCatching {
+ if (it.text.isEmpty()) {
+ postViewModel.price = TextFieldValue("")
+ } else if (it.text.toLongOrNull() != null) {
+ postViewModel.price = it
+ }
+ }
+ },
+ placeholder = {
+ Text(
+ text = "1000",
+ color = MaterialTheme.colorScheme.placeholderText
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Number
+ ),
+ singleLine = true,
+ colors = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent
+ )
+ )
+ }
+
+ Divider()
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.classifieds_condition),
+ fontSize = Font14SP,
+ fontWeight = FontWeight.W500
+ )
+
+ val conditionTypes = listOf(
+ Triple(ClassifiedsEvent.CONDITION.NEW, stringResource(id = R.string.classifieds_condition_new), stringResource(id = R.string.classifieds_condition_new_explainer)),
+ Triple(ClassifiedsEvent.CONDITION.USED_LIKE_NEW, stringResource(id = R.string.classifieds_condition_like_new), stringResource(id = R.string.classifieds_condition_like_new_explainer)),
+ Triple(ClassifiedsEvent.CONDITION.USED_GOOD, stringResource(id = R.string.classifieds_condition_good), stringResource(id = R.string.classifieds_condition_good_explainer)),
+ Triple(ClassifiedsEvent.CONDITION.USED_FAIR, stringResource(id = R.string.classifieds_condition_fair), stringResource(id = R.string.classifieds_condition_fair_explainer))
+ )
+
+ val conditionOptions = remember { conditionTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
+
+ TextSpinner(
+ placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
+ options = conditionOptions,
+ onSelect = {
+ postViewModel.condition = conditionTypes[it].first
+ },
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 5.dp, bottom = 1.dp)
+ ) { currentOption, modifier ->
+ MyTextField(
+ value = TextFieldValue(currentOption),
+ onValueChange = {},
+ readOnly = true,
+ modifier = modifier,
+ singleLine = true,
+ colors = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent
+ )
+ )
+ }
+ }
+
+ Divider()
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.classifieds_category),
+ fontSize = Font14SP,
+ fontWeight = FontWeight.W500
+ )
+
+ val categoryList = listOf(
+ R.string.classifieds_category_clothing,
+ R.string.classifieds_category_accessories,
+ R.string.classifieds_category_electronics,
+ R.string.classifieds_category_furniture,
+ R.string.classifieds_category_collectibles,
+ R.string.classifieds_category_books,
+ R.string.classifieds_category_pets,
+ R.string.classifieds_category_sports,
+ R.string.classifieds_category_fitness,
+ R.string.classifieds_category_art,
+ R.string.classifieds_category_crafts,
+ R.string.classifieds_category_home,
+ R.string.classifieds_category_office,
+ R.string.classifieds_category_food,
+ R.string.classifieds_category_misc,
+ R.string.classifieds_category_other
+ )
+
+ val categoryTypes = categoryList.map {
+ Triple(it, stringResource(id = it), null)
+ }
+
+ val categoryOptions = remember { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() }
+ TextSpinner(
+ placeholder = categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second ?: "",
+ options = categoryOptions,
+ onSelect = {
+ postViewModel.category = TextFieldValue(categoryTypes[it].second)
+ },
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 5.dp, bottom = 1.dp)
+ ) { currentOption, modifier ->
+ MyTextField(
+ value = TextFieldValue(currentOption),
+ onValueChange = {},
+ readOnly = true,
+ modifier = modifier,
+ singleLine = true,
+ colors = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent
+ )
+ )
+ }
+ }
+
+ Divider()
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.classifieds_location),
+ fontSize = Font14SP,
+ fontWeight = FontWeight.W500
+ )
+
+ MyTextField(
+ value = postViewModel.locationText,
+ onValueChange = {
+ postViewModel.locationText = it
+ },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = {
+ Text(
+ text = stringResource(R.string.classifieds_location_placeholder),
+ color = MaterialTheme.colorScheme.placeholderText
+ )
+ },
+ visualTransformation = UrlUserTagTransformation(
+ MaterialTheme.colorScheme.primary
+ ),
+ colors = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent
+ )
+ )
+ }
+
+ Divider()
+ }
+}
+
@Composable
fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) {
Column(
@@ -1213,6 +1460,36 @@ private fun ForwardZapTo(
}
}
+@Composable
+private fun AddClassifiedsButton(
+ postViewModel: NewPostViewModel,
+ onClick: () -> Unit
+) {
+ IconButton(
+ onClick = {
+ onClick()
+ }
+ ) {
+ if (!postViewModel.wantsProduct) {
+ Icon(
+ imageVector = Icons.Default.Sell,
+ contentDescription = stringResource(R.string.classifieds),
+ modifier = Modifier
+ .size(20.dp),
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Default.Sell,
+ contentDescription = stringResource(id = R.string.classifieds),
+ modifier = Modifier
+ .size(20.dp),
+ tint = BitcoinOrange
+ )
+ }
+ }
+}
+
@Composable
private fun MarkAsSensitive(
postViewModel: NewPostViewModel,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
index 7671cc838..ea55db6ef 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
@@ -33,7 +33,9 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
+import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
+import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
@@ -94,6 +96,14 @@ open class NewPostViewModel() : ViewModel() {
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(ClassifiedsEvent.CONDITION.USED_LIKE_NEW)
+
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
@@ -273,6 +283,23 @@ open class NewPostViewModel() : ViewModel() {
relayList,
geoHash
)
+ } else if (wantsProduct) {
+ account?.sendClassifieds(
+ title = title.text,
+ price = Price(price.text, "SATS", null),
+ condition = condition,
+ message = tagger.message,
+ replyTo = tagger.replyTos,
+ mentions = tagger.mentions,
+ location = locationText.text,
+ category = category.text,
+ directMentions = tagger.directMentions,
+ zapReceiver = zapReceiver,
+ wantsToMarkAsSensitive = wantsToMarkAsSensitive,
+ zapRaiserAmount = localZapRaiserAmount,
+ relayList = relayList,
+ geohash = geoHash
+ )
} else {
// adds markers
val rootId =
@@ -338,6 +365,7 @@ open class NewPostViewModel() : ViewModel() {
}
},
onError = {
+ Log.e("ImageUploader", "Failed to upload the image / video", it)
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
@@ -381,6 +409,10 @@ open class NewPostViewModel() : ViewModel() {
wantsZapraiser = false
zapRaiserAmount = null
+ wantsProduct = false
+ condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
+ price = TextFieldValue("")
+
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
@@ -527,6 +559,7 @@ open class NewPostViewModel() : ViewModel() {
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
+ (!wantsProduct || (!title.text.isNullOrBlank() && !price.text.isNullOrBlank() && !category.text.isNullOrBlank())) &&
contentToAddUrl == null
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt
index 26a881d22..de5f26254 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -43,6 +44,30 @@ fun TextSpinner(
options: ImmutableList,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
+) {
+ TextSpinner(
+ placeholder,
+ options,
+ onSelect,
+ modifier
+ ) { currentOption, modifier ->
+ OutlinedTextField(
+ value = currentOption,
+ onValueChange = {},
+ readOnly = true,
+ label = { label?.let { Text(it) } },
+ modifier = modifier
+ )
+ }
+}
+
+@Composable
+fun TextSpinner(
+ placeholder: String,
+ options: ImmutableList,
+ onSelect: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+ mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit
) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
@@ -50,16 +75,16 @@ fun TextSpinner(
var currentText by remember { mutableStateOf(placeholder) }
Box(
- modifier = modifier
+ modifier = modifier,
+ contentAlignment = Alignment.Center
) {
- OutlinedTextField(
- value = currentText,
- onValueChange = {},
- readOnly = true,
- label = { label?.let { Text(it) } },
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(focusRequester)
+ mainElement(
+ currentText,
+ remember {
+ Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ }
)
Box(
modifier = Modifier
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e2115edb0..5bad273bc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -673,4 +673,44 @@
Send the seller a message
Hi %1$s, is this still available?
Hi there, is this still available?
+
+ Sell an Item
+
+ Title
+ iPhone 13
+ Condition
+ Category
+ Price (in Sats)
+ 1000
+ Location
+ City, State, Country
+
+ New
+ It\'s a brand new unit, in the original box
+
+ Like New
+ It\'s used, but there are no signs of usage
+
+ Good
+ It has some superficial usage marks
+
+ Fair
+ It is still in acceptable and functional shape
+
+ Clothing
+ Accessories
+ Electronics
+ Furniture
+ Collectibles
+ Books
+ Pets
+ Sports
+ Fitness
+ Art
+ Crafts
+ Home
+ Office
+ Food
+ Miscellaneous
+ Other
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt
index 107e46f46..8f4f2fdf2 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt
@@ -19,6 +19,7 @@ class ClassifiedsEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
+ fun condition() = tags.firstOrNull { it.size > 1 && it[0] == "condition" }?.get(1)
fun images() = tags.filter { it.size > 1 && it[0] == "image" }.map { it[1] }
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
fun price() = tags.firstOrNull { it.size > 1 && it[0] == "price" }?.let {
@@ -32,23 +33,65 @@ class ClassifiedsEvent(
null
}
+ enum class CONDITION(val value: String){
+ NEW("new"),
+ USED_LIKE_NEW("like new"),
+ USED_GOOD("good"),
+ USED_FAIR("fair"),
+ }
+
companion object {
const val kind = 30402
+ private val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
fun create(
dTag: String,
title: String?,
image: String?,
summary: String?,
+ message: String,
price: Price?,
location: String?,
- publishedAt: Long?,
+ category: String?,
+ condition: ClassifiedsEvent.CONDITION?,
+ publishedAt: Long? = TimeUtils.now(),
+ replyTos: List?,
+ addresses: List?,
+ mentions: List?,
+ directMentions: Set,
+ zapReceiver: List? = null,
+ markAsSensitive: Boolean,
+ zapRaiserAmount: Long?,
+ geohash: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ClassifiedsEvent) -> Unit
) {
val tags = mutableListOf>()
+ replyTos?.forEach {
+ if (it in directMentions) {
+ tags.add(arrayOf("e", it, "", "mention"))
+ } else {
+ tags.add(arrayOf("e", it))
+ }
+ }
+ mentions?.forEach {
+ if (it in directMentions) {
+ tags.add(arrayOf("p", it, "", "mention"))
+ } else {
+ tags.add(arrayOf("p", it))
+ }
+ }
+ addresses?.forEach {
+ val aTag = it.toTag()
+ if (aTag in directMentions) {
+ tags.add(arrayOf("a", aTag, "", "mention"))
+ } else {
+ tags.add(arrayOf("a", aTag))
+ }
+ }
+
tags.add(arrayOf("d", dTag))
title?.let { tags.add(arrayOf("title", it)) }
image?.let { tags.add(arrayOf("image", it)) }
@@ -62,11 +105,36 @@ class ClassifiedsEvent(
tags.add(arrayOf("price", it.amount))
}
}
+ category?.let { tags.add(arrayOf("t", it)) }
location?.let { tags.add(arrayOf("location", it)) }
publishedAt?.let { tags.add(arrayOf("publishedAt", it.toString())) }
- title?.let { tags.add(arrayOf("title", it)) }
+ condition?.let { tags.add(arrayOf("condition", it.value)) }
- signer.sign(createdAt, kind, tags.toTypedArray(), "", onReady)
+ findHashtags(message).forEach {
+ tags.add(arrayOf("t", it))
+ tags.add(arrayOf("t", it.lowercase()))
+ }
+ zapReceiver?.forEach {
+ tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
+ }
+ findURLs(message).forEach {
+ val removedParamsFromUrl = it.split("?")[0].lowercase()
+ if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
+ tags.add(arrayOf("image", it))
+ }
+ tags.add(arrayOf("r", it))
+ }
+ if (markAsSensitive) {
+ tags.add(arrayOf("content-warning", ""))
+ }
+ zapRaiserAmount?.let {
+ tags.add(arrayOf("zapraiser", "$it"))
+ }
+ geohash?.let {
+ tags.addAll(geohashMipMap(it))
+ }
+
+ signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady)
}
}
}