1. Adds Classified creation from Amethyst.

2. Adds relay information for Replaceable events.
pull/714/head
Vitor Pamplona 2023-12-05 21:44:49 -05:00
rodzic 8f1fbe10e9
commit f458f00edc
9 zmienionych plików z 581 dodań i 61 usunięć

Wyświetl plik

@ -3,30 +3,39 @@
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

Wyświetl plik

@ -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)

Wyświetl plik

@ -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<Note>?,
mentions: List<User>?,
directMentions: Set<HexKey>,
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
relayList: List<Relay>? = 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<Note>?,
@ -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)
}
}

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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,

Wyświetl plik

@ -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>(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
}

Wyświetl plik

@ -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<TitleExplainer>,
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<TitleExplainer>,
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

Wyświetl plik

@ -673,4 +673,44 @@
<string name="send_the_seller_a_message">Send the seller a message</string>
<string name="hi_seller_is_this_still_available">Hi %1$s, is this still available?</string>
<string name="hi_there_is_this_still_available">Hi there, is this still available?</string>
<string name="classifieds">Sell an Item</string>
<string name="classifieds_title">Title</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Condition</string>
<string name="classifieds_category">Category</string>
<string name="classifieds_price">Price (in Sats)</string>
<string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">Location</string>
<string name="classifieds_location_placeholder">City, State, Country</string>
<string name="classifieds_condition_new">New</string>
<string name="classifieds_condition_new_explainer">It\'s a brand new unit, in the original box</string>
<string name="classifieds_condition_like_new">Like New</string>
<string name="classifieds_condition_like_new_explainer">It\'s used, but there are no signs of usage</string>
<string name="classifieds_condition_good">Good</string>
<string name="classifieds_condition_good_explainer">It has some superficial usage marks</string>
<string name="classifieds_condition_fair">Fair</string>
<string name="classifieds_condition_fair_explainer">It is still in acceptable and functional shape</string>
<string name="classifieds_category_clothing">Clothing</string>
<string name="classifieds_category_accessories">Accessories</string>
<string name="classifieds_category_electronics">Electronics</string>
<string name="classifieds_category_furniture">Furniture</string>
<string name="classifieds_category_collectibles">Collectibles</string>
<string name="classifieds_category_books">Books</string>
<string name="classifieds_category_pets">Pets</string>
<string name="classifieds_category_sports">Sports</string>
<string name="classifieds_category_fitness">Fitness</string>
<string name="classifieds_category_art">Art</string>
<string name="classifieds_category_crafts">Crafts</string>
<string name="classifieds_category_home">Home</string>
<string name="classifieds_category_office">Office</string>
<string name="classifieds_category_food">Food</string>
<string name="classifieds_category_misc">Miscellaneous</string>
<string name="classifieds_category_other">Other</string>
</resources>

Wyświetl plik

@ -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<String>?,
addresses: List<ATag>?,
mentions: List<String>?,
directMentions: Set<HexKey>,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ClassifiedsEvent) -> Unit
) {
val tags = mutableListOf<Array<String>>()
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)
}
}
}