diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 42ce00cb0b..795e7fe46c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint import com.vitorpamplona.amethyst.commons.ui.note.RenderFedimint import com.vitorpamplona.amethyst.commons.ui.note.RenderMintRecommendation +import com.vitorpamplona.amethyst.commons.ui.note.isCommunityDefinition +import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache @@ -1707,7 +1709,7 @@ fun RenderRepost( accountViewModel: AccountViewModel, nav: INav, ) { - note.replyTo?.lastOrNull { it.event !is CommunityDefinitionEvent }?.let { + note.replyTo?.lastOrNull { !it.isCommunityDefinition() }?.let { NoteCompose( it, modifier = Modifier, @@ -1995,14 +1997,7 @@ fun JumpToParentReplyButton( val parentNote = remember(baseNote) { - val noteEvent = baseNote.event as? BaseThreadedEvent ?: return@remember null - val direct = - noteEvent - .replyingToAddressOrEvent() - ?.let { accountViewModel.getNoteIfExists(it) } - ?.takeIf { it.event !is CommunityDefinitionEvent && LocalCache.getAnyChannel(it) == null } - val resolved = direct ?: baseNote.replyTo?.lastOrNull { it.event !is CommunityDefinitionEvent } - resolved?.takeIf { LocalCache.getAnyChannel(it) == null } + replyingDirectlyTo(baseNote, LocalCache)?.takeIf { LocalCache.getAnyChannel(it) == null } } ?: return ClickableBox( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayCommentScope.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayCommentScope.kt new file mode 100644 index 0000000000..13f07ac4d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayCommentScope.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.nip22Comments + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip72ModCommunities.topLevelCommunityAddress +import com.vitorpamplona.quartz.nip73ExternalIds.scope + +/** + * The context card above a comment whose parent isn't a note. + * + * A NIP-22 comment can be scoped to something that never resolves to an in-cache parent: an + * external identifier (NIP-73 `I` tag -- a url, hashtag or geohash) or, for a top-level post in a + * NIP-72 community, the community itself. Both render here so the two call sites -- the feed row + * and the thread's own master note -- don't each repeat the choice. + * + * Renders nothing when the screen is already dedicated to this exact scope (see + * [LocalCurrentExternalScope]), which would otherwise repeat the same preview on every row. + */ +@Composable +fun DisplayCommentScope( + noteEvent: CommentEvent, + accountViewModel: AccountViewModel, + nav: INav, +) { + val community = remember(noteEvent) { noteEvent.topLevelCommunityAddress() } + + if (community != null) { + val scopeKey = remember(community) { community.toValue() } + if (scopeKey != LocalCurrentExternalScope.current) { + DisplayCommunityScope(community, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } + return + } + + val scope = remember(noteEvent) { noteEvent.scope() } + if (scope != null && scope.toScope() != LocalCurrentExternalScope.current) { + DisplayExternalId(scope, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayCommunityScope.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayCommunityScope.kt new file mode 100644 index 0000000000..1c524ef01b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayCommunityScope.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.nip22Comments + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeaderNoActions +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip01Core.core.Address + +/** + * The parent-context card for a top-level NIP-72 community post. + * + * Such a post answers the community itself, so there is no parent *note* to render -- without + * this the slot above the post is simply empty. [ShortCommunityHeaderNoActions] is reused because + * it degrades gracefully: it falls back to the address' `d` tag while the definition event is + * still in flight, and its `observeNoteEvent` is what asks the relays for that definition in the + * first place. + */ +@Composable +fun DisplayCommunityScope( + community: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + val communityNote = remember(community) { LocalCache.getOrCreateAddressableNote(community) } + + ShortCommunityHeaderNoActions( + baseNote = communityNote, + accountViewModel = accountViewModel, + nav = nav, + modifier = + MaterialTheme.colorScheme.replyModifier + .clickable { nav.nav(Route.Community(community.kind, community.pubKeyHex, community.dTag)) } + .padding(10.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt index deca589468..6f937c3d73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt @@ -57,10 +57,10 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId /** - * The normalized scope (e.g. [ExternalId.toScope]) of the external content a screen is - * currently dedicated to, if any. A screen built entirely around one external scope (e.g. - * the URL thread screen) provides this so nested comments sharing that same scope don't - * redundantly repeat the preview the screen itself already shows. + * The scope a screen is currently dedicated to, if any: a normalized external id + * (e.g. [ExternalId.toScope]) for the URL thread screen, or a community address value for the + * community screen. A screen built entirely around one scope provides this so comments sharing + * that same scope don't redundantly repeat the preview the screen itself already shows. */ val LocalCurrentExternalScope = staticCompositionLocalOf { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index a1a477820a..adbf3da17f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -368,10 +368,11 @@ fun ShortCommunityHeaderNoActions( baseNote: AddressableNote, accountViewModel: AccountViewModel, nav: INav, + modifier: Modifier = Modifier, ) { val noteEvent by observeNoteEvent(baseNote, accountViewModel) - Row(verticalAlignment = Alignment.CenterVertically) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { noteEvent?.image()?.let { RobohashFallbackAsyncImage( robot = baseNote.idHex, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 02347f4c91..c2e9aeb62b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel +import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning @@ -46,8 +47,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags -import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId -import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope +import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayCommentScope import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer @@ -60,8 +60,6 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip73ExternalIds.scope enum class ReplyRenderType { FULL, @@ -117,29 +115,12 @@ fun RenderTextEvent( } } - val replyingDirectlyTo = - remember(note) { - if (noteEvent is BaseThreadedEvent) { - val replyingTo = noteEvent.replyingToAddressOrEvent() - if (replyingTo != null) { - val newNote = accountViewModel.getNoteIfExists(replyingTo) - if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { - newNote - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } + val parentNote = remember(note) { replyingDirectlyTo(note, LocalCache) } - if (replyingDirectlyTo != null && canShowReply) { + if (parentNote != null && canShowReply) { when (unPackReply) { ReplyRenderType.FULL -> { - ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + ReplyNoteComposition(parentNote, backgroundColor, accountViewModel, nav) Spacer(modifier = StdVertSpacer) } @@ -147,12 +128,12 @@ fun RenderTextEvent( // Zap receipts are signed by the recipient's lightning provider; // label the reply with the zap sender instead of the service key. val zapSender = - if (replyingDirectlyTo.event is LnZapEvent) { - observeZapSender(replyingDirectlyTo, accountViewModel).value + if (parentNote.event is LnZapEvent) { + observeZapSender(parentNote, accountViewModel).value } else { null } - val parentAuthor = zapSender ?: replyingDirectlyTo.author + val parentAuthor = zapSender ?: parentNote.author if (parentAuthor != null) { ReplyToLabel( parentAuthorDisplay = parentAuthor.toBestDisplayName(), @@ -163,15 +144,8 @@ fun RenderTextEvent( } } } else if (!makeItShort && noteEvent is CommentEvent) { - // A comment scoped to an external identifier (`I` tag) has no in-cache parent - // note. Show the scope itself as the reply context -- unless the screen we're - // in is already dedicated to this exact scope (e.g. the URL thread screen), - // in which case every row would otherwise redundantly repeat the same preview. - val scope = remember(note) { noteEvent.scope() } - if (scope != null && scope.toScope() != LocalCurrentExternalScope.current) { - DisplayExternalId(scope, accountViewModel, nav) - Spacer(modifier = StdVertSpacer) - } + // No in-cache parent note: this comment answers a scope rather than a note. + DisplayCommentScope(noteEvent, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt index d99aa11976..c4002a03b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.commons.ui.note.replyingDirectlyTo import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning @@ -48,7 +49,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @Composable fun RenderZapPoll( @@ -73,22 +73,9 @@ fun RenderZapPoll( } if (showReply) { - val replyingDirectlyTo = - remember(note) { - val replyingTo = noteEvent.replyingToAddressOrEvent() - if (replyingTo != null) { - val newNote = accountViewModel.getNoteIfExists(replyingTo) - if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { - newNote - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } - if (replyingDirectlyTo != null) { - ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + val parentNote = remember(note) { replyingDirectlyTo(note, LocalCache) } + if (parentNote != null) { + ReplyNoteComposition(parentNote, backgroundColor, accountViewModel, nav) Spacer(modifier = StdVertSpacer) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt index 3aec4e7ab8..08e333b3e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityActionOptions import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeaderNoActions @@ -193,26 +195,31 @@ fun CommunityScreen( }, accountViewModel = accountViewModel, ) { - HorizontalPager( - state = pagerState, - ) { page -> - when (page) { - 0 -> { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) - } + // Every row here is a post in this one community, which the top bar already names. Telling + // the rows which scope they are inside stops each of them from repeating the same + // community card as its parent context. + CompositionLocalProvider(LocalCurrentExternalScope provides note.idHex) { + HorizontalPager( + state = pagerState, + ) { page -> + when (page) { + 0 -> { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } - 1 -> { - RefresheableFeedView( - modFeedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) + 1 -> { + RefresheableFeedView( + modFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 6856ea3045..ae152bbf9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -133,7 +133,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle -import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId +import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayCommentScope import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader @@ -333,7 +333,6 @@ import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost -import com.vitorpamplona.quartz.nip73ExternalIds.scope import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent @@ -1137,15 +1136,11 @@ private fun FullBleedNoteCompose( nav, ) } else if (noteEvent is CommentEvent) { - // A comment scoped to external content (NIP-73 `I` tag, e.g. a URL) has no - // in-cache parent note, so it surfaces here as the thread's own "master" note. - // Show its external scope for context, same as the reply-context branch in - // RenderTextEvent does for non-root occurrences of the same comment. - val scope = remember(baseNote) { noteEvent.scope() } - if (scope != null) { - DisplayExternalId(scope, accountViewModel, nav) - Spacer(modifier = StdVertSpacer) - } + // A comment that answers a scope rather than a note has no in-cache parent, so + // it surfaces here as the thread's own "master" note. Show that scope for + // context, same as the reply-context branch in RenderTextEvent does for + // non-root occurrences of the same comment. + DisplayCommentScope(noteEvent, accountViewModel, nav) RenderTextEvent( baseNote, false, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 1a6f7c803f..be6da5ffa0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -396,6 +396,16 @@ open class Note( open fun address(): Address? = null + /** + * This note's kind, known even before its event arrives: an addressable note carries its kind + * in the address itself. Prefer this over `event?.kind` whenever a kind is being *excluded* -- + * an uncached note has a null event, so `event?.kind != SOME_KIND` silently passes for every + * note that simply hasn't loaded yet. + */ + fun kindOrNull(): Int? = address()?.kind ?: event?.kind + + fun isKind(kind: Int) = kindOrNull() == kind + open fun createdAt() = event?.createdAt fun isDraft() = event is DraftWrapEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNote.kt new file mode 100644 index 0000000000..3c1df2afd3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNote.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.isTopLevelCommunityPost + +/** + * The note to render as the "replying to" card above [note], or null when there is no parent to + * show. Prefers the parent the event itself names, falling back to the last resolved reply target. + * + * NIP-72 communities are deliberately never returned. A community is not a note a post replies + * to in any renderable sense: it is already named in the post's header and gets its own context + * card. Two shapes have to be caught for that to hold: + * + * - the parent resolves to the community's [com.vitorpamplona.amethyst.commons.model.AddressableNote]; + * - the post is a top-level community post whose bridged tags point at the definition event by + * *id* (mostr does this), which resolves to a plain [Note] that can never load, because + * addressable events are cached by address and not by id. + * + * Both used to slip through a `note.event?.kind != CommunityDefinitionEvent.KIND` test, because an + * uncached note has a null event and `null != 34550`. The result was an empty card where the + * parent context belongs. + */ +fun replyingDirectlyTo( + note: Note, + cache: ICacheProvider, +): Note? { + val event = note.event + + if (event is CommentEvent && event.isTopLevelCommunityPost()) return null + + val direct = + (event as? BaseThreadedEvent) + ?.replyingToAddressOrEvent() + ?.let { cache.getNoteIfExists(it) } + ?.takeIf { cache.getAnyChannel(it) == null && !it.isCommunityDefinition() } + + return direct ?: note.replyTo?.lastOrNull { !it.isCommunityDefinition() } +} + +/** + * True for a community definition whether or not its event has arrived. See [Note.kindOrNull] for + * why the address has to be consulted instead of the event. + */ +fun Note.isCommunityDefinition() = isKind(CommunityDefinitionEvent.KIND) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNoteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNoteTest.kt new file mode 100644 index 0000000000..5331ac9187 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ParentNoteTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ParentNoteTest { + private val communityOwner = "9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b" + private val definitionEventId = "575117c37d66a698ddd81169f88fcdab5d5d63687f79da0c5c65f1d72cb99a57" + private val moviesAddress = Address(34550, communityOwner, "movies") + private val movies = moviesAddress.toValue() + + private fun comment(vararg tags: Array) = + CommentEvent( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + tags = arrayOf(*tags), + content = "Ciao a tutti", + sig = "22".repeat(64), + ) + + private fun noteOf(event: Event) = Note(event.id).apply { this.event = event } + + /** + * The reported bug. A mostr-bridged top-level post in the "movies" community, viewed before + * the community definition has been fetched: the cache hands back an empty AddressableNote, + * which the old `event?.kind != KIND` test accepted (null != 34550) and rendered as a blank + * "replying to" card. + */ + @Test + fun topLevelCommunityPostHasNoParentWhenTheDefinitionIsNotCachedYet() { + val event = + comment( + arrayOf("A", movies, "wss://relay.mostr.pub/"), + arrayOf("a", movies, "wss://relay.mostr.pub/"), + arrayOf("E", definitionEventId, "wss://relay.mostr.pub/", communityOwner), + arrayOf("K", "34550"), + arrayOf("e", definitionEventId, "wss://relay.mostr.pub/", communityOwner), + arrayOf("k", "34550"), + ) + + // An AddressableNote that exists in the cache but whose definition event has not arrived. + val unloadedCommunity = AddressableNote(moviesAddress) + val cache = StubCache(notesById = mapOf(movies to unloadedCommunity)) + + assertNull(replyingDirectlyTo(noteOf(event), cache)) + } + + /** + * A reply whose own parent isn't tagged: `replyingToAddressOrEvent()` falls back to the root + * `A`, so the resolved candidate is the community. This is the path the top-level guard does + * *not* cover -- it reaches the address-kind exclusion, which has to hold even though the + * community's definition event has never loaded and its `event` is therefore null. + */ + @Test + fun replyFallingBackToTheCommunityRootHasNoParent() { + val event = + comment( + arrayOf("A", movies), + arrayOf("K", "34550"), + arrayOf("k", "1111"), + ) + + val unloadedCommunity = AddressableNote(moviesAddress) + val cache = StubCache(notesById = mapOf(movies to unloadedCommunity)) + + assertNull(unloadedCommunity.event, "the exclusion must not depend on the definition being cached") + assertNull(replyingDirectlyTo(noteOf(event), cache)) + } + + /** + * The bridged shape that points at the definition by *id*. That id can never resolve, because + * addressable events are cached by address -- so it must not be offered as a parent either. + */ + @Test + fun bridgedCommunityPostPointingAtTheDefinitionByIdHasNoParent() { + val event = + comment( + arrayOf("E", definitionEventId, "", communityOwner), + arrayOf("K", "34550"), + arrayOf("e", definitionEventId, "", communityOwner), + arrayOf("k", "34550"), + ) + + val note = noteOf(event) + // What LocalCache builds for an `e` tag: a plain Note keyed by id that stays empty. + val neverLoads = Note(definitionEventId) + note.replyTo = listOf(neverLoads) + val cache = StubCache(notesById = mapOf(definitionEventId to neverLoads)) + + assertNull(replyingDirectlyTo(note, cache)) + } + + /** + * A reply to a post *inside* a community keeps `K` = 34550 but points `k` at the parent's + * kind. The real parent must still be resolved -- the fix must not swallow these. + */ + @Test + fun nestedReplyInsideACommunityStillResolvesItsParent() { + val parentId = "33".repeat(32) + val event = + comment( + arrayOf("A", movies), + arrayOf("K", "34550"), + arrayOf("e", parentId), + arrayOf("k", "1111"), + ) + + val parent = Note(parentId) + val note = noteOf(event) + note.replyTo = listOf(AddressableNote(moviesAddress), parent) + val cache = StubCache(notesById = mapOf(parentId to parent, movies to AddressableNote(moviesAddress))) + + assertSame(parent, replyingDirectlyTo(note, cache)) + } + + /** + * The predicate `RenderRepost` now shares (`note.replyTo?.lastOrNull { !it.isCommunityDefinition() }`). + * The composable itself needs an instrumented test, but the logic that changed is this + * predicate: it has to recognise the community from the address, with no event loaded. + */ + @Test + fun communityIsRecognisedWithoutItsDefinitionEvent() { + val uncached = AddressableNote(moviesAddress) + assertNull(uncached.event, "precondition: the definition has not arrived") + assertTrue(uncached.isCommunityDefinition(), "an uncached community must still be excluded") + + val article = AddressableNote(Address(30023, communityOwner, "some-article")) + assertFalse(article.isCommunityDefinition(), "other addressable kinds must not be excluded") + + val plainNote = Note("55".repeat(32)) + assertFalse(plainNote.isCommunityDefinition(), "a note with no event and no address is not a community") + } + + /** The selection `RenderRepost` performs: skip the community, take the real boosted note. */ + @Test + fun repostSelectionSkipsAnUncachedCommunityAndTakesTheRealNote() { + val boosted = Note("66".repeat(32)) + val replyTo = listOf(AddressableNote(moviesAddress), boosted) + + assertSame(boosted, replyTo.lastOrNull { !it.isCommunityDefinition() }) + + // Community-only: nothing to render, rather than the empty shell that produced the blank. + assertNull(listOf(AddressableNote(moviesAddress)).lastOrNull { !it.isCommunityDefinition() }) + } + + /** A post in a channel is rendered by the channel header, not as a parent note. */ + @Test + fun noteInsideAChannelIsNotOfferedAsAParent() { + val parentId = "44".repeat(32) + val event = comment(arrayOf("e", parentId), arrayOf("k", "1111")) + val parent = Note(parentId) + val note = noteOf(event) + val cache = StubCache(notesById = mapOf(parentId to parent), channelFor = setOf(parentId)) + + assertNull(replyingDirectlyTo(note, cache)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt index 59d608d092..88c2a79113 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt @@ -20,16 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.ui.note -import com.vitorpamplona.amethyst.commons.model.AddressableNote -import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream -import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.test.Test import kotlin.test.assertEquals @@ -107,31 +99,4 @@ class ReplyContextTest { assertTrue(rawAddress.contains(":")) assertTrue(!parentEventId.contains(":")) } - - private class StubCache( - private val notesById: Map, - private val users: Map = emptyMap(), - ) : ICacheProvider { - override fun getAnyChannel(note: Note): Channel? = null - - override val relayHints = HintIndexer() - - override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] - - override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 - - override fun getNoteIfExists(hexKey: HexKey): Note? = notesById[hexKey] - - override fun checkGetOrCreateNote(hexKey: HexKey): Note? = notesById[hexKey] - - override fun getOrCreateAddressableNote(address: Address): AddressableNote = error("not used by ReplyContext.from") - - override fun getEventStream(): ICacheEventStream = error("not used by ReplyContext.from") - - override fun hasBeenDeleted(event: Any): Boolean = false - - override fun getOrCreateUser(pubkey: HexKey): User? = users[pubkey] - - override fun justConsumeMyOwnEvent(event: Event): Boolean = false - } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StubCache.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StubCache.kt new file mode 100644 index 0000000000..b8cfc4611f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StubCache.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer + +/** + * Minimal [ICacheProvider] for the pure reply-resolution tests in this package: a fixed set of + * notes and users, and an optional set of note ids that should report as living in a channel. + * Everything the resolvers never touch throws rather than returning a plausible-looking default. + */ +internal class StubCache( + private val notesById: Map = emptyMap(), + private val users: Map = emptyMap(), + private val channelFor: Set = emptySet(), +) : ICacheProvider { + override val relayHints = HintIndexer() + + override fun getAnyChannel(note: Note): Channel? = + if (note.idHex in channelFor) { + object : Channel() { + override fun toBestDisplayName() = "a channel" + } + } else { + null + } + + override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = notesById[hexKey] + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = notesById[hexKey] + + override fun getOrCreateAddressableNote(address: Address): AddressableNote = error("unused by the reply resolvers") + + override fun getEventStream(): ICacheEventStream = error("unused by the reply resolvers") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User? = users[pubkey] + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index 17110cce9d..efeb2a7ae2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.core.fastAny import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider @@ -148,8 +149,14 @@ class CommentEvent( fun directReplies() = tags.filter { ReplyIdentifierTag.match(it) || ReplyAddressTag.match(it) || ReplyEventTag.match(it) } + /** Whether a parent is named at all, without materialising [directReplies]. */ + fun hasDirectReplies() = tags.fastAny { ReplyIdentifierTag.match(it) || ReplyAddressTag.match(it) || ReplyEventTag.match(it) } + fun directKinds() = tags.filter(ReplyKindTag::match) + /** Whether a parent kind (`k`) is declared at all, without materialising [directKinds]. */ + fun hasDirectKinds() = tags.fastAny(ReplyKindTag::match) + fun rootAuthor() = tags.firstNotNullOfOrNull(RootAuthorTag::parse) fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/EventExt.kt index 6fd9bec3bd..5931e9ceaf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/EventExt.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.tags.RootAddressTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag @@ -44,8 +45,9 @@ fun Event.isForCommunity(communityAddressId: String): Boolean = fun Event.communityAddress(): Address? = if (this is CommentEvent) { - this.rootAddress().firstNotNullOfOrNull { - if (it.kind == CommunityDefinitionEvent.KIND) it else null + // Stops at the first community root address instead of parsing every `A` tag into a list. + this.tags.fastFirstNotNullOfOrNull { tag -> + RootAddressTag.parseAddress(tag)?.takeIf { it.kind == CommunityDefinitionEvent.KIND } } } else { this.tags.fastFirstNotNullOfOrNull(CommunityTag::parseAddress) @@ -53,6 +55,35 @@ fun Event.communityAddress(): Address? = fun CommentEvent.isCommunityScoped() = hasRootScopeKind(CommunityDefinitionEvent.KIND_STR) +/** + * True when this comment answers the community itself -- a top-level post in the community -- + * as opposed to a nested reply to another post inside it. + * + * NIP-22 keeps the uppercase root tags (`A`/`E`/`K`) identical all the way down a thread, so + * [isCommunityScoped] is true for every comment in a community, however deep. What separates a + * top-level post from a reply is the lowercase parent kind (`k`): 34550 for the former, the + * parent post's kind for the latter. Bridges (e.g. mostr) sometimes emit only the uppercase set, + * hence the fallback to the root kind when no `k` is present. + */ +fun CommentEvent.isTopLevelCommunityPost(): Boolean { + // An explicit parent kind (`k`) is authoritative: it names what this comment answers. + if (hasDirectKinds()) return hasReplyScopeKind(CommunityDefinitionEvent.KIND_STR) + + // No `k`. Decide from what the comment actually points at rather than guessing: a parent + // *address* that is a community means top level; a parent *event* means it answers a post + // inside the community, not the community itself. + if (hasDirectReplies()) return replyAddressIds().any { Address.isOfKind(it, CommunityDefinitionEvent.KIND_STR) } + + // Nothing names a parent at all -- the shape bridges emit, with only the uppercase root set. + return hasRootScopeKind(CommunityDefinitionEvent.KIND_STR) +} + +/** + * The community this comment is a top-level post in, or null when it is a reply to another post + * (or not a community comment at all). Checks the cheap kind tags before parsing any address. + */ +fun CommentEvent.topLevelCommunityAddress(): Address? = if (isTopLevelCommunityPost()) communityAddress() else null + fun CommentEvent.communityScope() = this.tags.fastFirstNotNullOfOrNull(CommunityTag::parseAddress) fun CommentEvent.communityScopes() = this.tags.mapNotNull(CommunityTag::parseAddress) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/CommunityCommentScopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/CommunityCommentScopeTest.kt new file mode 100644 index 0000000000..e5c1479435 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/CommunityCommentScopeTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 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.nip72ModCommunities + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CommunityCommentScopeTest { + private fun comment(vararg tags: Array) = + CommentEvent( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + tags = arrayOf(*tags), + content = "hello", + sig = "22".repeat(64), + ) + + private val communityOwner = "9ca0bd7450742d6a20319c0e3d4c679c9e046a9dc70e8ef55c2905e24052340b" + private val movies = "34550:$communityOwner:movies" + + /** + * The report that motivated this: a mostr-bridged top-level post in the "movies" community. + * It carries the community both as `A`/`a` and -- unusually -- as an `E`/`e` pointing at the + * definition event's id, with `K`/`k` = 34550. + */ + private fun bridgedTopLevelPost() = + comment( + arrayOf("A", movies, "wss://relay.mostr.pub/"), + arrayOf("a", movies, "wss://relay.mostr.pub/"), + arrayOf("E", "575117c37d66a698ddd81169f88fcdab5d5d63687f79da0c5c65f1d72cb99a57", "wss://relay.mostr.pub/", communityOwner), + arrayOf("K", "34550"), + arrayOf("P", communityOwner, "wss://nostr.wine/"), + arrayOf("e", "575117c37d66a698ddd81169f88fcdab5d5d63687f79da0c5c65f1d72cb99a57", "wss://relay.mostr.pub/", communityOwner), + arrayOf("k", "34550"), + arrayOf("p", communityOwner, "wss://nostr.wine/"), + ) + + @Test + fun bridgedTopLevelPostIsRecognisedAsAnswerToTheCommunity() { + val event = bridgedTopLevelPost() + + assertTrue(event.isCommunityScoped()) + assertTrue(event.isTopLevelCommunityPost()) + assertEquals(movies, (event as Event).communityAddress()?.toValue()) + } + + /** + * The community must not become a "replying to" card: [tagsWithoutCitations] returning empty + * is what keeps it out of `Note.replyTo`, and the UI relies on that. + */ + @Test + fun bridgedTopLevelPostHasNoReplyTargets() { + assertEquals(emptyList(), bridgedTopLevelPost().tagsWithoutCitations()) + } + + @Test + fun topLevelPostWithOnlyUppercaseRootTagsStillCounts() { + val event = + comment( + arrayOf("A", movies), + arrayOf("K", "34550"), + ) + + assertTrue(event.isTopLevelCommunityPost()) + } + + /** + * A reply to a post inside a community keeps `K` = 34550 but points `k` at the parent's kind, + * so it must still resolve to a real parent note rather than to the community. + */ + @Test + fun nestedReplyInsideACommunityIsNotATopLevelPost() { + val parentId = "33".repeat(32) + val event = + comment( + arrayOf("A", movies), + arrayOf("K", "34550"), + arrayOf("e", parentId), + arrayOf("k", "1111"), + ) + + assertTrue(event.isCommunityScoped()) + assertFalse(event.isTopLevelCommunityPost()) + assertTrue(event.tagsWithoutCitations().contains(parentId)) + } + + /** + * A nested reply that omits the parent kind (`k`) entirely -- malformed, but emitted in the + * wild. It must not be mistaken for a top-level post just because the root kind says 34550: + * it names a parent *event*, so it answers a post inside the community, not the community. + */ + @Test + fun nestedReplyWithoutAParentKindIsNotATopLevelPost() { + val event = + comment( + arrayOf("A", movies), + arrayOf("K", "34550"), + arrayOf("e", "33".repeat(32)), + ) + + assertTrue(event.isCommunityScoped()) + assertFalse(event.isTopLevelCommunityPost()) + } + + /** The mirror case: no `k`, but the parent *address* is the community, so it is top level. */ + @Test + fun postNamingTheCommunityAsItsParentAddressIsATopLevelPost() { + val event = + comment( + arrayOf("A", movies), + arrayOf("a", movies), + arrayOf("K", "34550"), + ) + + assertTrue(event.isTopLevelCommunityPost()) + } + + /** A reply whose parent address is some other addressable event is not a top-level post. */ + @Test + fun replyToANonCommunityAddressIsNotATopLevelPost() { + val event = + comment( + arrayOf("A", movies), + arrayOf("a", "30023:$communityOwner:some-article"), + arrayOf("K", "34550"), + ) + + assertFalse(event.isTopLevelCommunityPost()) + } + + /** + * [communityAddress] was rewritten to stop at the first community root address instead of + * parsing every `A` tag into a list. This pins it against the original algorithm as a + * reference oracle -- seven call sites outside this PR depend on it being unchanged. + */ + @Test + fun communityAddressMatchesTheOriginalAlgorithmOnEveryShape() { + val other = "30023:$communityOwner:some-article" + val secondCommunity = "34550:$communityOwner:films" + + val shapes = + listOf( + "no tags at all" to comment(), + "one community root" to comment(arrayOf("A", movies)), + "non-community root only" to comment(arrayOf("A", other)), + "non-community first, community second" to comment(arrayOf("A", other), arrayOf("A", movies)), + "two communities picks the first" to comment(arrayOf("A", movies), arrayOf("A", secondCommunity)), + "unparseable root" to comment(arrayOf("A", "not-an-address"), arrayOf("A", movies)), + "reply address only, no root" to comment(arrayOf("a", movies)), + "root without a value" to comment(arrayOf("A")), + ) + + shapes.forEach { (name, event) -> + assertEquals( + originalCommunityAddress(event)?.toValue(), + (event as Event).communityAddress()?.toValue(), + "communityAddress diverged from the original algorithm for: $name", + ) + } + } + + /** The pre-rewrite implementation, kept here purely as the oracle for the test above. */ + private fun originalCommunityAddress(event: CommentEvent) = + event.rootAddress().firstNotNullOfOrNull { + if (it.kind == CommunityDefinitionEvent.KIND) it else null + } + + @Test + fun commentOnSomethingOtherThanACommunityIsNotATopLevelPost() { + val event = + comment( + arrayOf("I", "https://example.com/article"), + arrayOf("K", "web"), + ) + + assertFalse(event.isCommunityScoped()) + assertFalse(event.isTopLevelCommunityPost()) + } +}