Merge pull request #3923 from davotoula/fix/community-comment-parent-context

fix: blank parent card above a top-level community post
This commit is contained in:
Vitor Pamplona
2026-08-14 11:36:47 -04:00
committed by GitHub
17 changed files with 764 additions and 134 deletions
@@ -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(
@@ -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)
}
}
@@ -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),
)
}
@@ -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<String?> { null }
@@ -368,10 +368,11 @@ fun ShortCommunityHeaderNoActions(
baseNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val noteEvent by observeNoteEvent<CommunityDefinitionEvent>(baseNote, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
noteEvent?.image()?.let {
RobohashFallbackAsyncImage(
robot = baseNote.idHex,
@@ -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)
}
}
@@ -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)
}
}
@@ -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,
)
}
}
}
}
@@ -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,
@@ -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
@@ -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)
@@ -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<String>) =
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))
}
}
@@ -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<HexKey, Note>,
private val users: Map<HexKey, User> = 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
}
}
@@ -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<HexKey, Note> = emptyMap(),
private val users: Map<HexKey, User> = emptyMap(),
private val channelFor: Set<HexKey> = 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
}
@@ -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)
@@ -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)
@@ -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<String>) =
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())
}
}