From af7284699836aa9f7bd4f59193c778bff0a2db3f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 13 Jul 2023 11:20:03 -0400 Subject: [PATCH] Support for Classifieds --- .../amethyst/model/LocalCache.kt | 20 ++ .../amethyst/service/NostrHomeDataSource.kt | 4 +- .../service/model/ClassifiedsEvent.kt | 77 ++++++++ .../amethyst/service/model/Event.kt | 1 + .../ui/dal/HomeNewThreadFeedFilter.kt | 3 +- .../amethyst/ui/note/NoteCompose.kt | 124 +++++++++++- .../amethyst/ui/screen/ThreadFeedView.kt | 181 ++++++++++++++---- 7 files changed, 368 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/ClassifiedsEvent.kt 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 fbfd81030..16ce08260 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -402,6 +402,25 @@ object LocalCache { } } + private fun consume(event: ClassifiedsEvent) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + } + + if (note.event?.id() == event.id()) return + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + } + private fun consume(event: PinListEvent) { val version = getOrCreateNote(event.id) val note = getOrCreateAddressableNote(event.address()) @@ -1306,6 +1325,7 @@ object LocalCache { is ChannelMessageEvent -> consume(event, relay) is ChannelMetadataEvent -> consume(event) is ChannelMuteUserEvent -> consume(event) + is ClassifiedsEvent -> consume(event) is CommunityDefinitionEvent -> consume(event, relay) is CommunityPostApprovalEvent -> { event.containedPost()?.let { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 0876cd519..362de96d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.service.model.AudioTrackEvent +import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent import com.vitorpamplona.amethyst.service.model.GenericRepostEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent @@ -66,6 +67,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { TextNoteEvent.kind, RepostEvent.kind, GenericRepostEvent.kind, + ClassifiedsEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, @@ -89,7 +91,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), tags = mapOf( "t" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ClassifiedsEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ClassifiedsEvent.kt new file mode 100644 index 000000000..12111a47b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ClassifiedsEvent.kt @@ -0,0 +1,77 @@ +package com.vitorpamplona.amethyst.service.model + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.TimeUtils +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils + +@Immutable +class ClassifiedsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) + + 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 summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + fun price() = tags.firstOrNull { it.size > 1 && it[0] == "price" }?.let { + Price(it[1], it.getOrNull(2), it.getOrNull(3)) + } + fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1) + + fun publishedAt() = try { + tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() + } catch (_: Exception) { + null + } + + companion object { + const val kind = 30402 + + fun create( + dTag: String, + title: String?, + image: String?, + summary: String?, + price: Price?, + location: String?, + publishedAt: Long?, + privateKey: ByteArray, + createdAt: Long = TimeUtils.now() + ): ClassifiedsEvent { + val tags = mutableListOf>() + + tags.add(listOf("d", dTag)) + title?.let { tags.add(listOf("title", it)) } + image?.let { tags.add(listOf("image", it)) } + summary?.let { tags.add(listOf("summary", it)) } + price?.let { + if (it.frequency != null && it.currency != null) { + tags.add(listOf("price", it.amount, it.currency, it.frequency)) + } else if (it.currency != null) { + tags.add(listOf("price", it.amount, it.currency)) + } else { + tags.add(listOf("price", it.amount)) + } + } + location?.let { tags.add(listOf("location", it)) } + publishedAt?.let { tags.add(listOf("publishedAt", it.toString())) } + title?.let { tags.add(listOf("title", it)) } + + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, "") + val sig = Utils.sign(id, privateKey) + return ClassifiedsEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey()) + } + } +} + +data class Price(val amount: String, val currency: String?, val frequency: String?) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 5d8f25d99..2fa014491 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -267,6 +267,7 @@ open class Event( ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) + ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 77174c9cf..a4e712bd9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TimeUtils import com.vitorpamplona.amethyst.service.model.AudioTrackEvent +import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent import com.vitorpamplona.amethyst.service.model.GenericRepostEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent @@ -42,7 +43,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter() .asSequence() .filter { it -> val noteEvent = it.event - (noteEvent is TextNoteEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) && + (noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) && (isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 7414a41d9..932bcda55 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent import com.vitorpamplona.amethyst.service.model.EmojiPackEvent @@ -169,6 +170,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdStartPadding @@ -840,11 +842,12 @@ fun ClickableNote( .combinedClickable( onClick = { scope.launch { - val redirectToNote = if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { - baseNote.replyTo?.lastOrNull() ?: baseNote - } else { - baseNote - } + val redirectToNote = + if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { + baseNote.replyTo?.lastOrNull() ?: baseNote + } else { + baseNote + } routeFor(redirectToNote, accountViewModel.userProfile())?.let { nav(it) } @@ -1003,7 +1006,8 @@ private fun RenderNoteRow( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - when (baseNote.event) { + val noteEvent = baseNote.event + when (noteEvent) { is AppDefinitionEvent -> { RenderAppDefinition(baseNote, accountViewModel, nav) } @@ -1067,6 +1071,14 @@ private fun RenderNoteRow( ) } + is ClassifiedsEvent -> { + RenderClassifieds( + noteEvent, + baseNote, + accountViewModel + ) + } + is HighlightEvent -> { RenderHighlight( baseNote, @@ -3447,6 +3459,106 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView } } +@Composable +private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountViewModel: AccountViewModel) { + 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() } + + Row( + modifier = Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colors.subtleBorder, + QuoteBorder + ) + ) { + Column { + Row() { + image?.let { + AsyncImage( + model = it, + contentDescription = stringResource( + R.string.preview_card_image_for, + it + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + } ?: CreateImageHeader(note, accountViewModel) + } + + Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), verticalAlignment = Alignment.CenterVertically) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.body1, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + } + + price?.let { + val priceTag = remember(noteEvent) { + if (price.frequency != null && price.currency != null) { + "${price.amount} ${price.currency}/${price.frequency}" + } else if (price.currency != null) { + "${price.amount} ${price.currency}" + } else { + price.amount + } + } + + Text( + text = priceTag, + maxLines = 1, + color = MaterialTheme.colors.primary, + fontWeight = FontWeight.Bold, + modifier = remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(start = 5.dp) + } + ) + } + } + + if (summary != null || location != null) { + Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), verticalAlignment = Alignment.CenterVertically) { + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.caption, + modifier = Modifier + .weight(1f), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + location?.let { + Text( + text = it, + style = MaterialTheme.typography.caption, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 5.dp) + ) + } + } + } + + Spacer(modifier = DoubleVertSpacer) + } + } +} + @Composable fun CreateImageHeader( note: Note, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 8b9070568..54bda3dda 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -40,6 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -48,6 +50,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp @@ -59,6 +62,7 @@ import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent import com.vitorpamplona.amethyst.service.model.EmojiPackEvent @@ -89,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader +import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote @@ -352,40 +357,9 @@ fun NoteMaster( if (noteEvent is BadgeDefinitionEvent) { BadgeDisplay(baseNote = note) } else if (noteEvent is LongTextNoteEvent) { - Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { - Column { - noteEvent.image()?.let { - AsyncImage( - model = it, - contentDescription = stringResource( - R.string.preview_card_image_for, - it - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(10.dp)) - } - - noteEvent.title()?.let { - Text( - text = it, - fontSize = 28.sp, - fontWeight = FontWeight.Light, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(10.dp)) - } - - noteEvent.summary()?.let { - Text( - text = it, - modifier = Modifier.fillMaxWidth(), - color = Color.Gray - ) - } - } - } + RenderLongFormHeaderForThread(noteEvent) + } else if (noteEvent is ClassifiedsEvent) { + RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel) } Row( @@ -492,3 +466,142 @@ fun NoteMaster( NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } } + +@Composable +private fun RenderClassifiedsReaderForThread( + noteEvent: ClassifiedsEvent, + note: Note, + accountViewModel: AccountViewModel +) { + 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() } + + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Column { + Row() { + image?.let { + AsyncImage( + model = it, + contentDescription = stringResource( + R.string.preview_card_image_for, + it + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + } ?: CreateImageHeader(note, accountViewModel) + } + + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.body1, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + } + + price?.let { + val priceTag = remember(noteEvent) { + if (price.frequency != null && price.currency != null) { + "${price.amount} ${price.currency}/${price.frequency}" + } else if (price.currency != null) { + "${price.amount} ${price.currency}" + } else { + price.amount + } + } + + Text( + text = priceTag, + maxLines = 1, + color = MaterialTheme.colors.primary, + fontWeight = FontWeight.Bold, + modifier = remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(start = 5.dp) + } + ) + } + } + + summary?.let { + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = it, + style = MaterialTheme.typography.caption, + modifier = Modifier.weight(1f), + color = Color.Gray, + overflow = TextOverflow.Ellipsis + ) + } + } + + location?.let { + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = it, + style = MaterialTheme.typography.caption, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +@Composable +private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) { + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Column { + noteEvent.image()?.let { + AsyncImage( + model = it, + contentDescription = stringResource( + R.string.preview_card_image_for, + it + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(10.dp)) + } + + noteEvent.title()?.let { + Text( + text = it, + fontSize = 28.sp, + fontWeight = FontWeight.Light, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(10.dp)) + } + + noteEvent.summary()?.let { + Text( + text = it, + modifier = Modifier.fillMaxWidth(), + color = Color.Gray + ) + } + } + } +}