mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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(
|
||||
|
||||
+66
@@ -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)
|
||||
}
|
||||
}
|
||||
+63
@@ -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),
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-19
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-11
@@ -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
|
||||
|
||||
+68
@@ -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)
|
||||
+186
@@ -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))
|
||||
}
|
||||
}
|
||||
-35
@@ -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)
|
||||
|
||||
+33
-2
@@ -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)
|
||||
|
||||
+203
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user