From 173e82d236603608fd72c1b7ca429070d887e470 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 23 Feb 2024 17:55:39 -0500 Subject: [PATCH] Adds Wikipage Kind --- .../amethyst/model/LocalCache.kt | 37 +++++ .../amethyst/ui/note/NoteCompose.kt | 101 +++++++++++- .../amethyst/ui/screen/ThreadFeedView.kt | 148 ++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + .../quartz/events/BaseTextNoteEvent.kt | 7 +- .../quartz/events/EventFactory.kt | 1 + .../quartz/events/WikiNoteEvent.kt | 87 ++++++++++ 7 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.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 5b88fb9f4..3214a11b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,6 +96,7 @@ import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent +import com.vitorpamplona.quartz.events.WikiNoteEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList @@ -395,6 +396,41 @@ object LocalCache { } } + fun consume( + event: WikiNoteEvent, + relay: Relay?, + ) { + 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 (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (antiSpam.isSpam(event, relay)) { + relay?.let { it.spamCounter++ } + return + } + + val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, replyTo) + + refreshObservers(note) + } + } + fun consume( event: PollNoteEvent, relay: Relay? = null, @@ -1848,6 +1884,7 @@ object LocalCache { is TextNoteEvent -> consume(event, relay) is VideoHorizontalEvent -> consume(event, relay) is VideoVerticalEvent -> consume(event, relay) + is WikiNoteEvent -> consume(event, relay) else -> { Log.w("Event Not Supported", event.toJson()) } 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 db4e2a79d..e25618332 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 @@ -225,6 +225,7 @@ import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent +import com.vitorpamplona.quartz.events.WikiNoteEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -1160,6 +1161,9 @@ private fun RenderNoteRow( is LongTextNoteEvent -> { RenderLongFormContent(baseNote, accountViewModel, nav) } + is WikiNoteEvent -> { + RenderWikiContent(baseNote, accountViewModel, nav) + } is BadgeAwardEvent -> { RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) } @@ -2916,12 +2920,19 @@ private fun LoadAndDisplayUrl(url: String) { } @Composable -private fun LoadAndDisplayUser( +fun LoadAndDisplayUser( userBase: User, nav: (String) -> Unit, ) { - val route = remember { "User/${userBase.pubkeyHex}" } + LoadAndDisplayUser(userBase, "User/${userBase.pubkeyHex}", nav) +} +@Composable +fun LoadAndDisplayUser( + userBase: User, + route: String, + nav: (String) -> Unit, +) { val userState by userBase.live().metadata.observeAsState() val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } val userTags = @@ -3660,6 +3671,92 @@ private fun LongFormHeader( } summary?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun RenderWikiContent( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? WikiNoteEvent ?: return + + WikiNoteHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +private fun WikiNoteHeader( + noteEvent: WikiNoteEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val title = remember(noteEvent) { noteEvent.title() } + val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + val image = remember(noteEvent) { noteEvent.image() } + + Row( + modifier = + Modifier + .padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } + + if (automaticallyShowUrlPreview) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: CreateImageHeader(note, accountViewModel) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + + summary?.let { + Spacer(modifier = StdVertSpacer) Text( text = it, style = MaterialTheme.typography.bodySmall, 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 36b6f75fb..a2e7ff250 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 @@ -76,21 +76,27 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note 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.mockAccountViewModel import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost import com.vitorpamplona.amethyst.ui.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.elements.DisplayReward import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits import com.vitorpamplona.amethyst.ui.elements.Reward +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.AudioHeader import com.vitorpamplona.amethyst.ui.note.AudioTrackHeader @@ -104,6 +110,8 @@ import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.HiddenNote +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.LoadAndDisplayUser import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu @@ -131,6 +139,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote @@ -144,6 +153,7 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.EmojiPackEvent +import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GenericRepostEvent @@ -155,11 +165,13 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.RelaySetEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.VideoEvent +import com.vitorpamplona.quartz.events.WikiNoteEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @Composable @@ -444,6 +456,8 @@ fun NoteMaster( BadgeDisplay(baseNote = note) } else if (noteEvent is LongTextNoteEvent) { RenderLongFormHeaderForThread(noteEvent) + } else if (noteEvent is WikiNoteEvent) { + RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) } else if (noteEvent is ClassifiedsEvent) { RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel, nav) } @@ -797,3 +811,137 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) { } } } + +@Preview +@Composable +private fun RenderWikiHeaderForThreadPreview() { + val event = Event.fromJson("{\"id\":\"277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1708695717,\"kind\":30818,\"tags\":[[\"d\",\"amethyst\"],[\"a\",\"30818:f03e7c5262648e0b7823dfb49f8f17309cfec9cb14711413dcabdf3d7fc6369a:amethyst\",\"wss://relay.nostr.band\",\"fork\"],[\"e\",\"ceabc60c8022c472c727aa25ae7691885964366386ce265c47e5a78be6cb00be\",\"wss://relay.nostr.band\",\"fork\"],[\"title\",\"Amethyst\"],[\"published_at\",\"1708707133\"]],\"content\":\"An Android-only app written in Kotlin with support for over 90 event kinds. \\n\\n![](https://play-lh.googleusercontent.com/lvZlAm9dBrpHeOo7sIPKCsiKOLYLhR2b0FiOT4tyiwWO2dvsR2gDS0xk9tOOr9U-6uM=w240-h480-rw)\\n\",\"sig\":\"6748126a909a20dbdb67947a09d64e41d7140a79335a4ad675c6173d7dd5dbcab9c360dec617bd67bbbc20dfad416b15056eda2e20716cd6c425a84301a125a0\"}") as WikiNoteEvent + val accountViewModel = mockAccountViewModel() + val nav: (String) -> Unit = {} + + runBlocking { + withContext(Dispatchers.IO) { + LocalCache.justConsume(event, null) + } + } + + LoadNote(baseNoteHex = "277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1", accountViewModel = accountViewModel) { baseNote -> + ThemeComparisonColumn( + onDark = { + val bg = MaterialTheme.colorScheme.background + val backgroundColor = + remember { + mutableStateOf(bg) + } + + Column { + RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav) + RenderTextEvent( + baseNote!!, + false, + true, + backgroundColor, + accountViewModel, + nav, + ) + } + }, + onLight = { + val bg = MaterialTheme.colorScheme.background + val backgroundColor = + remember { + mutableStateOf(bg) + } + + Column { + RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav) + RenderTextEvent( + baseNote!!, + false, + true, + backgroundColor, + accountViewModel, + nav, + ) + } + }, + ) + } +} + +@Composable +private fun RenderWikiHeaderForThread( + noteEvent: WikiNoteEvent, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } + + 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(), + ) + } + + noteEvent.title()?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + ) + } + + forkedAddress?.let { + LoadAddressableNote(aTag = it, accountViewModel = accountViewModel) { originalVersion -> + if (originalVersion != null) { + ShowForkInformation(originalVersion, Modifier.fillMaxWidth(), accountViewModel, nav) + } + } + } + + noteEvent + .summary() + ?.ifBlank { null } + ?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + modifier = Modifier.fillMaxWidth(), + color = Color.Gray, + ) + } + } + } +} + +@Composable +fun ShowForkInformation( + originalVersion: AddressableNote, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by originalVersion.live().metadata.observeAsState() + val note = noteState?.note ?: return + val author = note.author ?: return + val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + + if (route != null) { + Row(modifier) { + Text(stringResource(id = R.string.forked_from)) + Spacer(modifier = StdHorzSpacer) + LoadAndDisplayUser(author, route, nav) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b0e87fa9..c48dd418b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -776,4 +776,5 @@ Thank you! Max Limit Restricted Writes + Forked from diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt index ef6f61a20..39ba084ed 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt @@ -43,7 +43,7 @@ open class BaseTextNoteEvent( fun mentions() = taggedUsers() open fun replyTos(): List { - val oldStylePositional = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + val oldStylePositional = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] } val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) @@ -153,7 +153,10 @@ open class BaseTextNoteEvent( fun tagsWithoutCitations(): List { val repliesTo = replyTos() val tagAddresses = - taggedAddresses().filter { it.kind != CommunityDefinitionEvent.KIND }.map { it.toTag() } + taggedAddresses().filter { + it.kind != CommunityDefinitionEvent.KIND && + it.kind != WikiNoteEvent.KIND + }.map { it.toTag() } if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() val citations = findCitations() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index b2fd06e2e..0c7956752 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -123,6 +123,7 @@ class EventFactory { VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) + WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> Event(id, pubKey, createdAt, kind, tags, content, sig) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt new file mode 100644 index 000000000..25bb51454 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/WikiNoteEvent.kt @@ -0,0 +1,87 @@ +/** + * 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.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class WikiNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(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 topics() = hashtags() + + fun forkFromAddress() = + tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "fork" }?.let { + val aTagValue = it[1] + val relay = it.getOrNull(2) + + ATag.parse(aTagValue, relay) + } + + fun forkFromVersion() = tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "fork" }?.get(1) + + fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + + fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.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 = 30818 + + fun create( + msg: String, + title: String?, + replyTos: List?, + mentions: List?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (WikiNoteEvent) -> Unit, + ) { + val tags = mutableListOf>() + replyTos?.forEach { tags.add(arrayOf("e", it)) } + mentions?.forEach { tags.add(arrayOf("p", it)) } + title?.let { tags.add(arrayOf("title", it)) } + tags.add(arrayOf("alt", "Wiki Post: $title")) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + } + } +}