mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(desktop): show reply context in feeds (parent embed + label)
Detect NIP-10 / NIP-22 replies in the desktop feed pipeline and render an embedded parent card plus a "Replying to @displayName" label above the reply body, matching Android's home-feed behavior. Extracts the shared ReplyToLabel composable + ReplyContext data class to commons so Android switches over to the shared version. - commons/.../ui/note/ReplyContext.kt: data class + from(event, cache) detection. NIP-10 + NIP-22 unified via BaseThreadedEvent polymorphism. - commons/.../ui/note/ReplyToLabel.kt: shared composable. - commons/strings.xml: new "Notes & Replies" section + replying_to key. - desktopApp NoteCard: replyContext param + render branch (bordered QuotedNoteEmbed + ReplyToLabel). Recursion impossible because QuotedNoteEmbed's inner NoteCard call doesn't pass replyContext. - desktopApp FeedScreen: rememberReplyContext() observes parent metadata flow so embed/label pop in once the parent arrives via relay subscription. Wired into both regular and reposted-inner paths. - amethyst ReplyInformation.kt: removed local ReplyToLabel definition. - amethyst Text.kt: calls shared commons ReplyToLabel; resolves author display name at the call site. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5a5a4a9c27
commit
ad8556a82c
@@ -114,31 +114,6 @@ fun ReplyInformationChannel(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReplyToLabel(
|
||||
replyingDirectlyTo: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val user = replyingDirectlyTo.author ?: return
|
||||
|
||||
FlowRow {
|
||||
Text(
|
||||
stringRes(id = R.string.replying_to),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
ReplyInfoMention(
|
||||
user = user,
|
||||
prefix = "",
|
||||
accountViewModel = accountViewModel,
|
||||
onUserTagClick = { nav.nav(routeFor(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyInfoMention(
|
||||
user: User,
|
||||
|
||||
@@ -35,15 +35,16 @@ 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.ReplyToLabel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
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.ReplyToLabel
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -114,12 +115,14 @@ fun RenderTextEvent(
|
||||
}
|
||||
|
||||
ReplyRenderType.LINE -> {
|
||||
ReplyToLabel(
|
||||
replyingDirectlyTo = replyingDirectlyTo,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
val parentAuthor = replyingDirectlyTo.author
|
||||
if (parentAuthor != null) {
|
||||
ReplyToLabel(
|
||||
parentAuthorDisplay = parentAuthor.toBestDisplayName(),
|
||||
onClick = { nav.nav(routeFor(parentAuthor)) },
|
||||
)
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!makeItShort && noteEvent is CommentEvent) {
|
||||
|
||||
@@ -54,4 +54,7 @@
|
||||
<!-- Accessibility -->
|
||||
<string name="accessibility_user_avatar">User avatar</string>
|
||||
<string name="accessibility_navigate">Navigate</string>
|
||||
|
||||
<!-- Notes & Replies -->
|
||||
<string name="replying_to">replying to </string>
|
||||
</resources>
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
data class ReplyContext(
|
||||
val parentNoteId: String?,
|
||||
val parentAuthorPubKey: String,
|
||||
val parentAuthorDisplay: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(
|
||||
event: BaseThreadedEvent,
|
||||
cache: ICacheProvider?,
|
||||
): ReplyContext? {
|
||||
val raw = event.replyingToAddressOrEvent() ?: return null
|
||||
val isAddressable = raw.contains(":")
|
||||
val parentNoteId = if (isAddressable) null else raw
|
||||
|
||||
val parentAuthorPubKey =
|
||||
when (event) {
|
||||
is CommentEvent -> event.replyAuthor()?.pubKey
|
||||
else -> null
|
||||
} ?: parentNoteId?.let { cache?.getNoteIfExists(it)?.author?.pubkeyHex }
|
||||
?: return null
|
||||
|
||||
val parentAuthorDisplay =
|
||||
cache?.getUserIfExists(parentAuthorPubKey)?.toBestDisplayName()
|
||||
?: (parentAuthorPubKey.take(8) + "…")
|
||||
|
||||
return ReplyContext(parentNoteId, parentAuthorPubKey, parentAuthorDisplay)
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.replying_to
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReplyToLabel(
|
||||
parentAuthorDisplay: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(Res.string.replying_to),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "@$parentAuthorDisplay",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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.nip10Notes.TextNoteEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReplyContextTest {
|
||||
private val parentEventId = "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c"
|
||||
private val parentAuthorPubKey = "4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0"
|
||||
|
||||
/** Non-reply text note. Just content, no e/a tags. */
|
||||
@Test
|
||||
fun nonReplyReturnsNull() {
|
||||
val event = TextNoteEvent("", "", 0, emptyArray(), "hello world", "")
|
||||
val ctx = ReplyContext.from(event, null)
|
||||
assertNull(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-10 reply with a marked "reply" e-tag. The author can't come from
|
||||
* a CommentEvent (kind 1 isn't one), so it must come from the cache's
|
||||
* parent-note author lookup.
|
||||
*/
|
||||
@Test
|
||||
fun markedReplyResolvesParentAuthorViaCache() {
|
||||
val event =
|
||||
TextNoteEvent(
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
arrayOf(
|
||||
arrayOf("e", parentEventId, "", "reply"),
|
||||
arrayOf("p", parentAuthorPubKey),
|
||||
),
|
||||
"agreed",
|
||||
"",
|
||||
)
|
||||
|
||||
val parentUser = User(parentAuthorPubKey) { addr -> Note(addr.toValue()) }
|
||||
val parentNote = Note(parentEventId).apply { author = parentUser }
|
||||
val cache = StubCache(notesById = mapOf(parentEventId to parentNote))
|
||||
|
||||
val ctx = ReplyContext.from(event, cache)
|
||||
assertEquals(parentEventId, ctx?.parentNoteId)
|
||||
assertEquals(parentAuthorPubKey, ctx?.parentAuthorPubKey)
|
||||
// No user metadata loaded — display falls back to truncated hex + ellipsis.
|
||||
assertTrue(ctx?.parentAuthorDisplay?.endsWith("…") == true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply with the parent NOT in the cache and no NIP-22 replyAuthor() tag.
|
||||
* from() can't resolve an author, so it bails out (returns null).
|
||||
* Recomposition picks up the label later once the parent arrives.
|
||||
*/
|
||||
@Test
|
||||
fun replyWithoutParentInCacheReturnsNull() {
|
||||
val event =
|
||||
TextNoteEvent(
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
arrayOf(arrayOf("e", parentEventId, "", "reply")),
|
||||
"agreed",
|
||||
"",
|
||||
)
|
||||
val cache = StubCache(notesById = emptyMap())
|
||||
val ctx = ReplyContext.from(event, cache)
|
||||
assertNull(ctx)
|
||||
}
|
||||
|
||||
/** Address coordinates contain colons; event IDs do not. */
|
||||
@Test
|
||||
fun addressCoordDiscriminatorContainsColon() {
|
||||
val rawAddress = "30023:$parentAuthorPubKey:my-article"
|
||||
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 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(key: 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
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.rememberNewPostsChipState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
||||
@@ -143,6 +144,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
@@ -257,6 +259,7 @@ fun FeedNoteCard(
|
||||
onFollow != null &&
|
||||
originalEvent.pubKey != myPubKeyHex &&
|
||||
originalEvent.pubKey !in followedUsers
|
||||
val innerReplyContext = rememberReplyContext(originalEvent, localCache)
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -301,6 +304,8 @@ fun FeedNoteCard(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
replyContext = innerReplyContext,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -326,6 +331,7 @@ fun FeedNoteCard(
|
||||
onFollow != null &&
|
||||
event.pubKey != myPubKeyHex &&
|
||||
event.pubKey !in followedUsers
|
||||
val replyContext = rememberReplyContext(event, localCache)
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -370,10 +376,53 @@ fun FeedNoteCard(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
replyContext = replyContext,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether [event] is a reply (NIP-10 or NIP-22), observes the
|
||||
* parent's metadata flow so the embed and label pop in once it arrives,
|
||||
* and returns a [ReplyContext] (or null for non-replies).
|
||||
*
|
||||
* For addressable parents (`a` coord), the embed is skipped and only the
|
||||
* "Replying to @X" label renders — see ReplyContext.from semantics.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberReplyContext(
|
||||
event: Event,
|
||||
localCache: DesktopLocalCache,
|
||||
): ReplyContext? {
|
||||
val threaded = event as? BaseThreadedEvent ?: return null
|
||||
|
||||
val replyTargetEventId =
|
||||
remember(event) {
|
||||
threaded
|
||||
.replyingToAddressOrEvent()
|
||||
?.takeUnless { it.contains(":") }
|
||||
}
|
||||
|
||||
// Observe parent metadata so recomposition picks up the parent event /
|
||||
// author when it arrives via relay subscription.
|
||||
val parentNote =
|
||||
replyTargetEventId?.let {
|
||||
remember(it) { localCache.getOrCreateNote(it) }
|
||||
}
|
||||
val parentFlow = parentNote?.let { remember(it) { it.flow() } }
|
||||
val parentMetaState = parentFlow?.metadata?.stateFlow?.collectAsState()
|
||||
val parentMetaValue = parentMetaState?.value
|
||||
|
||||
DisposableEffect(parentNote) {
|
||||
onDispose { parentNote?.clearFlow() }
|
||||
}
|
||||
|
||||
return remember(event, parentMetaValue) {
|
||||
ReplyContext.from(threaded, localCache)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
fun FeedScreen(
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.desktop.ui.note
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -55,6 +56,8 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
|
||||
@@ -103,6 +106,8 @@ fun NoteCard(
|
||||
onPayInvoice: ((String) -> Unit)? = null,
|
||||
bottomContent: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
headerTrailingContent: (@Composable () -> Unit)? = null,
|
||||
replyContext: ReplyContext? = null,
|
||||
onNavigateToThread: ((String) -> Unit)? = null,
|
||||
) {
|
||||
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
|
||||
val imageUrls =
|
||||
@@ -160,6 +165,36 @@ fun NoteCard(
|
||||
val cardShape = MaterialTheme.shapes.medium
|
||||
val cardBody: @Composable ColumnScope.() -> Unit = {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Reply context — embedded parent + "Replying to @X" label.
|
||||
// Rendered above the standard author/timestamp row.
|
||||
replyContext?.let { ctx ->
|
||||
val parentNoteId = ctx.parentNoteId
|
||||
if (parentNoteId != null) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
) {
|
||||
QuotedNoteEmbed(
|
||||
noteId = parentNoteId,
|
||||
localCache = localCache,
|
||||
onMentionClick = onMentionClick,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
)
|
||||
}
|
||||
}
|
||||
ReplyToLabel(
|
||||
parentAuthorDisplay = ctx.parentAuthorDisplay,
|
||||
onClick = { onAuthorClick?.invoke(ctx.parentAuthorPubKey) },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
---
|
||||
title: Desktop Feed — Reply Context Rendering
|
||||
type: fix
|
||||
status: active
|
||||
date: 2026-06-05
|
||||
origin: docs/brainstorms/2026-06-05-desktop-feed-reply-context-brainstorm.md
|
||||
---
|
||||
|
||||
# Desktop Feed — Reply Context Rendering
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-06-05
|
||||
|
||||
Source-verified facts (carried into the final design):
|
||||
|
||||
- **NIP-10 / NIP-22 unified** — `BaseThreadedEvent.replyingToAddressOrEvent()` is overridden by `CommentEvent` (kind 1111). One polymorphic call covers both, no caller-side branching.
|
||||
- **Return type** — `replyingToAddressOrEvent(): String?` is a flat string. Disambiguate event-id vs address by `raw.contains(":")`.
|
||||
- **`QuotedNoteEmbed` is reusable as-is** — signature `(noteId: String, localCache, onMentionClick?, onNavigateToThread?)`. Already handles cache lookup, loading-state, and async parent arrival.
|
||||
- **Addressable parents** — v1 renders label-only. `QuotedNoteEmbed` doesn't accept `Address`; embed is follow-up.
|
||||
- **Existing styling** — `MaterialTheme.colorScheme.outlineVariant` (1.dp border, used in `ThreadScreen.kt:283`). No new theme keys.
|
||||
- **FlowRow** — available in `commons/commonMain/` (live precedent: `commons/.../nip23LongContent/ui/editor/MetadataPanel.kt:26`).
|
||||
- **Strings location** — `commons/src/commonMain/composeResources/values/strings.xml`. Package `com.vitorpamplona.amethyst.commons.resources`. Domain-grouped, NOT alphabetized — add new `<!-- Notes & Replies -->` section.
|
||||
- **Reposted-reply** — FeedScreen.kt:131 extracts inner note via `note.replyTo?.lastOrNull()` then calls `NoteCard` on it. Phase 3 wiring passes a fresh `replyContext` for the inner note — reply context engages naturally on the reposted reply.
|
||||
- **Cache parent observation pattern** — `note.flow().metadata.stateFlow.collectAsState()` drives recomposition on parent arrival.
|
||||
|
||||
## Review Findings Applied (2026-06-05)
|
||||
|
||||
After deepen-plan, three parallel reviewers (architecture-strategist,
|
||||
code-simplicity-reviewer, pattern-recognition-specialist) flagged structural
|
||||
issues. Applied revisions:
|
||||
|
||||
1. **`ReplyContext` moves to `commons/commonMain/`** (architect's blocker).
|
||||
Pure protocol→display data with no platform deps; if left in desktopApp,
|
||||
Android re-implements when it extracts.
|
||||
2. **Drop the `withReplyContext: Boolean` flag** (simplicity). The flag
|
||||
existed only to break recursion. Instead: keep `NoteDisplayData` strictly
|
||||
display-only and pass `replyContext: ReplyContext?` as a *separate*
|
||||
parameter to `NoteCard`. `QuotedNoteEmbed` calls `NoteCard` without a
|
||||
`replyContext` (default null) — recursion impossible by construction. No
|
||||
`toNoteDisplayData` callsite audit, no behavior surprise for bookmarks /
|
||||
search / quote-mentions.
|
||||
3. **Drop `ReplyRenderType` extraction** (simplicity). Desktop only uses
|
||||
FULL; Android's LINE/NONE branches stay in `amethyst/`. Enum stays where
|
||||
it is.
|
||||
4. **Simplify `parentAuthorHint()`** — drop the brittle last-`p`-tag
|
||||
heuristic. Use only:
|
||||
- `CommentEvent.replyAuthor()` for NIP-22, OR
|
||||
- Read `parent.pubKey` from cache once parent loads.
|
||||
If neither resolves: omit the label (don't show "Replying to @unknown").
|
||||
Eventual consistency wins over speculative guesses.
|
||||
5. **`strings.xml` section header** — add new `<!-- Notes & Replies -->`
|
||||
group; the file is domain-grouped, not alphabetized.
|
||||
6. **Skip the commons `ReplyToLabelTest` smoke test** — testing a
|
||||
two-`Text` composable is theater. Real coverage is the desktop converter
|
||||
test.
|
||||
|
||||
**Gentle deviation from brainstorm:** the brainstorm option chosen was
|
||||
phrased "add to NoteDisplayData". On sharper analysis, attaching the field
|
||||
forces a depth-cap mechanism (the flag). Decoupling — pass `ReplyContext`
|
||||
alongside `NoteDisplayData` rather than embedded inside it — preserves the
|
||||
brainstorm's core intent ("FeedScreen pre-computes once per item, NoteCard
|
||||
is a pure render fn") while eliminating the flag. Flagged for explicit
|
||||
user awareness.
|
||||
|
||||
## Overview
|
||||
|
||||
Desktop home feed currently renders reply notes as standalone top-level posts —
|
||||
no indication they're replies, no parent context. Users lose conversational
|
||||
thread when scrolling the feed. Fix: detect NIP-10 / NIP-22 replies during
|
||||
event→display-data conversion, then render an embedded parent card plus a
|
||||
"Replying to @displayName" label above the reply body — matching Android's
|
||||
`ReplyRenderType.FULL` mode (but capped at 1 quote level for the deck-column
|
||||
layout).
|
||||
|
||||
Per brainstorm decisions (see brainstorm:
|
||||
`docs/brainstorms/2026-06-05-desktop-feed-reply-context-brainstorm.md`):
|
||||
|
||||
- Render mode: **FULL** (label + embedded parent card)
|
||||
- Extraction: move shared composables into `commons/commonMain/` now
|
||||
- Scope: NIP-10 (kind 1) **and** NIP-22 (kind 1111)
|
||||
- Quote depth: **1 level** (no recursive grandparent)
|
||||
- Data flow: pre-compute `ReplyContext` in `Event.toNoteDisplayData()`
|
||||
|
||||
## Problem Statement
|
||||
|
||||
`desktopApp/.../FeedScreen.kt` currently special-cases reposts only (lines
|
||||
128–197). Replies fall through and are rendered by `NoteCard` (NoteCard.kt:95+)
|
||||
as if they were thread-root posts. Three concrete user impacts:
|
||||
|
||||
1. **Lost thread context.** Reply text often only makes sense relative to the
|
||||
parent ("yes!", "no this is wrong because…"). Without the parent the
|
||||
feed reads as decontextualized noise.
|
||||
2. **Lost author cue.** No indication who is being replied to — feed user
|
||||
can't tell from a glance whether they care.
|
||||
3. **Parity gap with Android.** Same account, same relays, same notes — the
|
||||
Android client shows the conversation structure; desktop doesn't.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Three discrete layers of work:
|
||||
|
||||
1. **Detection** — reuse `BaseThreadedEvent.replyingToAddressOrEvent()` from
|
||||
`quartz/` (already KMP-common, already covers both NIP-10 and NIP-22).
|
||||
No new code needed in `quartz/`.
|
||||
2. **Shared UI** — extract `ReplyRenderType` enum and `ReplyToLabel`
|
||||
composable from `amethyst/` to `commons/commonMain/`. Refactor away
|
||||
Android-only deps (`R.string` → `Res.string`, `nav.nav(routeFor(...))` →
|
||||
click callback).
|
||||
3. **Desktop wiring** — extend `NoteDisplayData` with an optional
|
||||
`replyContext: ReplyContext?` field, populate it inside
|
||||
`Event.toNoteDisplayData(cache)`, and render it from `NoteCard` using
|
||||
the shared `ReplyToLabel` + the existing desktop `QuotedNoteEmbed()`
|
||||
(NoteCard.kt:488–535) for the parent card.
|
||||
|
||||
## Survey Matrix (per CLAUDE.md convention)
|
||||
|
||||
| Component | Status | Location | Action |
|
||||
|---|---|---|---|
|
||||
| `replyingToAddressOrEvent()` | ✅ Reuse | `quartz/.../nip10Notes/BaseThreadedEvent.kt` | Call as-is, no changes |
|
||||
| `ReplyRenderType` enum | ⚠️ Avoid | `amethyst/.../ui/note/types/Text.kt:59-63` | Leave in Android. Desktop only uses FULL; extracting is YAGNI |
|
||||
| `ReplyToLabel` composable | 📦 Extract | `amethyst/.../ui/note/ReplyInformation.kt:121-142` | Refactor + move to commons; Android switches to shared version |
|
||||
| `ReplyInfoMention` helper | ⚠️ Avoid | `amethyst/.../ui/note/ReplyInformation.kt:145-163` | Leave in Android. v1 ReplyToLabel takes plain `String`; emoji-in-name regression flagged |
|
||||
| `ReplyNoteComposition` | ⚠️ Avoid | `amethyst/.../ui/note/NoteCompose.kt:1491-1508` | Hard-coupled to `NoteCompose` (1100+ LOC). Desktop renders parent via its own `QuotedNoteEmbed`. |
|
||||
| `ReplyContext` data class | 🆕 New | `commons/commonMain/.../ui/note/ReplyContext.kt` | Pure protocol→display struct. Lives in commons so Android can adopt later. |
|
||||
| Desktop `QuotedNoteEmbed` | ✅ Reuse | `desktopApp/.../ui/note/NoteCard.kt:488-535` | Use unchanged. Recursion impossible because we pass `replyContext` as a separate param to `NoteCard`, not via `NoteDisplayData`. |
|
||||
| Desktop `NoteCard` | 📦 Extend | `desktopApp/.../ui/note/NoteCard.kt:81-88, 95+` | Add optional `replyContext: ReplyContext?` param, branch render |
|
||||
| Reply detection / `ReplyContext` build | 🆕 New | `commons/commonMain/.../ui/note/ReplyContext.kt` (companion `from(event, cache)`) | Pure function: `BaseThreadedEvent` + `ICacheProvider` → `ReplyContext?` |
|
||||
| `Event.toNoteDisplayData()` | ✅ Reuse | `desktopApp/.../ui/EventExtensions.kt:32-53` | UNCHANGED. Reply detection happens separately in FeedScreen's item render block. |
|
||||
| `DesktopLocalCache.getNoteIfExists` | ✅ Reuse | `desktopApp/.../cache/DesktopLocalCache.kt:564-571` | Call from converter for parent lookup |
|
||||
| `User.toBestDisplayName()` | ✅ Reuse | `commons/.../model/User.kt:90` | Author label for "Replying to @X" |
|
||||
| FeedScreen repost pattern | ✅ Mirror | `desktopApp/.../ui/FeedScreen.kt:128-197` | Apply same flow-observation pattern for parent recompose |
|
||||
| `Res.string.replying_to` | 🆕 New | `commons/src/commonMain/composeResources/values/strings.xml` | Add string key |
|
||||
| `Reply.kt` icon | ✅ Reuse | `commons/.../icons/Reply.kt` | Already extracted |
|
||||
| Reply embed visual border | 🆕 New | `desktopApp/.../ui/note/NoteCard.kt` | Add small subtle-border style; Android has `replyModifier` but desktop has none |
|
||||
|
||||
**Legend:** ✅ Reuse · 📦 Extract / Extend · 🆕 New · ⚠️ Avoid (duplicate / blocked)
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ desktopApp/ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ FeedScreen.kt — LazyColumn item block │ │
|
||||
│ │ observe note + parent flowSet.metadata │ │
|
||||
│ │ call Event.toNoteDisplayData(cache) → NoteDisplayData│ │
|
||||
│ │ └─ with replyContext: ReplyContext? populated │ │
|
||||
│ │ render NoteCard(noteDisplayData) │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ NoteCard.kt — render path branch │ │
|
||||
│ │ if (replyContext != null): │ │
|
||||
│ │ Column { │ │
|
||||
│ │ QuotedNoteEmbed(replyContext.parent) │ │
|
||||
│ │ ReplyToLabel(parentAuthor, onUserClick) │ │
|
||||
│ │ <existing body render> │ │
|
||||
│ │ } │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ depends on (shared)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ commons/commonMain/ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ ui/note/ReplyToLabel.kt (NEW) │ │
|
||||
│ │ @Composable fun ReplyToLabel( │ │
|
||||
│ │ parentAuthor: User, │ │
|
||||
│ │ onUserClick: (User) -> Unit, │ │
|
||||
│ │ ) │ │
|
||||
│ │ ui/note/ReplyRenderType.kt (NEW) │ │
|
||||
│ │ resources/strings.xml — adds `replying_to` │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ used by
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ amethyst/ (Android) │
|
||||
│ ReplyInformation.kt::ReplyToLabel — DELETED │
|
||||
│ Callers updated: pass `onUserClick = { u → nav.nav(routeFor(u)) }`│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Shape: `ReplyContext`
|
||||
|
||||
Lives in `commons/commonMain/.../ui/note/ReplyContext.kt`:
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.commons.ui.note
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
|
||||
data class ReplyContext(
|
||||
/** Hex event id of the parent. Null when the reply targets only an addressable (`a` coord). */
|
||||
val parentNoteId: String?,
|
||||
/** Hex pubkey of the parent's author. Used by the click handler. */
|
||||
val parentAuthorPubKey: String,
|
||||
/** Resolved display name (or truncated hex fallback) for the "Replying to @X" label. */
|
||||
val parentAuthorDisplay: String,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Pure detection. Returns null if [event] is not a reply, or if we
|
||||
* can't determine the parent author yet (caller should retry once
|
||||
* the parent event arrives in cache).
|
||||
*/
|
||||
fun from(event: BaseThreadedEvent, cache: ICacheProvider?): ReplyContext? {
|
||||
val raw = event.replyingToAddressOrEvent() ?: return null
|
||||
val isAddressable = raw.contains(":")
|
||||
val parentNoteId = if (isAddressable) null else raw
|
||||
|
||||
val parentAuthorPubKey = when (event) {
|
||||
is CommentEvent -> event.replyAuthor()
|
||||
else -> null
|
||||
} ?: parentNoteId?.let { cache?.getNoteIfExists(it)?.author?.pubkeyHex }
|
||||
?: return null
|
||||
|
||||
val parentAuthorDisplay = cache?.getUserIfExists(parentAuthorPubKey)
|
||||
?.toBestDisplayName()
|
||||
?: parentAuthorPubKey.take(8) + "…"
|
||||
|
||||
return ReplyContext(parentNoteId, parentAuthorPubKey, parentAuthorDisplay)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Three fields.** Dropped from earlier draft:
|
||||
- `isAddressableParent: Boolean` — redundant; just check `parentNoteId == null`.
|
||||
- Andriod's last-`p`-tag fallback path — fragile and only saved one render
|
||||
frame. Now: if we can't resolve the author (parent not cached, not a
|
||||
CommentEvent), `from()` returns null and the reply renders as a regular
|
||||
post. When the parent event arrives via subscription, recomposition
|
||||
re-evaluates `from()` and the label appears.
|
||||
|
||||
**ReplyContext is computed at the FeedScreen item-render level**, not at
|
||||
event→display-data conversion time. It's passed as a *separate parameter*
|
||||
to `NoteCard`, not embedded in `NoteDisplayData`. This is the structural
|
||||
fix from the architecture/simplicity review: `NoteDisplayData` stays a
|
||||
pure display struct; `NoteCard` doesn't need a flag to suppress embedded
|
||||
recursion (the recursive `QuotedNoteEmbed → NoteCard` chain never receives
|
||||
a `replyContext` parameter, so it can never render one). Recursion
|
||||
impossible by construction.
|
||||
|
||||
### `NoteDisplayData` — UNCHANGED
|
||||
|
||||
After the review pass, `NoteDisplayData` is **not** modified. It stays a
|
||||
strictly display-only snapshot. Reply context flows alongside it through
|
||||
the `NoteCard` parameter list. This preserves single-responsibility and
|
||||
removes the need to audit `toNoteDisplayData`'s 9 callsites.
|
||||
|
||||
### Reply Detection — `ReplyContext.from(event, cache)`
|
||||
|
||||
All detection logic lives in the `ReplyContext.from()` companion function
|
||||
(spec'd above). `Event.toNoteDisplayData` is **not** modified. The
|
||||
FeedScreen item-render block calls `ReplyContext.from()` separately when
|
||||
preparing each feed item.
|
||||
|
||||
**NIP-10 / NIP-22 unification confirmed.**
|
||||
`BaseThreadedEvent.replyingToAddressOrEvent()` at
|
||||
`quartz/.../BaseThreadedEvent.kt:78-84` is **overridden** by
|
||||
`CommentEvent.replyingToAddressOrEvent()` at
|
||||
`quartz/.../nip22Comments/CommentEvent.kt:224` — polymorphic dispatch
|
||||
makes our caller blind to which NIP it's handling. One code path.
|
||||
|
||||
**Return type:** `replyingToAddressOrEvent(): String?` returns a flat
|
||||
string with no discriminant. Disambiguation: `raw.contains(":")` —
|
||||
addresses use `kind:pubkey:d-tag` format; event IDs are pure hex.
|
||||
|
||||
### FeedScreen Integration
|
||||
|
||||
Mirror the repost pattern at FeedScreen.kt:128–152, which uses
|
||||
`originalNote.flow()` → `NoteFlowSet` → `.metadata.stateFlow.collectAsState()`.
|
||||
The `Note.flow()` accessor lives at `commons/.../model/Note.kt:913-916` and
|
||||
returns a `NoteFlowSet` with `metadata`, `reactions`, `replies`, `zaps` —
|
||||
all `StateFlow<T>`.
|
||||
|
||||
```kotlin
|
||||
// For each LazyColumn item, before rendering NoteCard:
|
||||
val displayData = remember(event, metadataState) {
|
||||
event.toNoteDisplayData(localCache)
|
||||
}
|
||||
|
||||
// Reply-context computation — observe parent metadata so the label/embed
|
||||
// pop in once the parent arrives.
|
||||
val replyTargetEventId = remember(event) {
|
||||
(event as? BaseThreadedEvent)
|
||||
?.replyingToAddressOrEvent()
|
||||
?.takeUnless { it.contains(":") } // skip addressable; label-only path
|
||||
}
|
||||
val parentNote = replyTargetEventId?.let {
|
||||
remember(it) { localCache.getOrCreateNote(it) }
|
||||
}
|
||||
val parentMetaState = parentNote?.let {
|
||||
remember(it) { it.flow() }.metadata.stateFlow.collectAsState()
|
||||
}
|
||||
val replyContext = remember(event, parentMetaState?.value) {
|
||||
(event as? BaseThreadedEvent)?.let { ReplyContext.from(it, localCache) }
|
||||
}
|
||||
|
||||
NoteCard(
|
||||
displayData = displayData,
|
||||
replyContext = replyContext, // NEW: sibling param
|
||||
localCache = localCache,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
```
|
||||
|
||||
**Why this works.**
|
||||
|
||||
- `getOrCreateNote` always returns a `Note` — placeholder with `event = null`
|
||||
if not yet fetched. The `NoteFlowSet.metadata` still emits when the event
|
||||
later arrives (verified — same pattern reposts use today).
|
||||
- `remember(event, parentMetaState?.value)` invalidates the `replyContext`
|
||||
computation when the parent's metadata changes. Parent embed pops in via
|
||||
recomposition without scroll-position jump.
|
||||
- The `?.takeUnless { it.contains(":") }` guards against trying to fetch an
|
||||
addressable note by event id; addressable parents render label-only.
|
||||
- `ReplyContext.from()` returns null when author can't be resolved — reply
|
||||
renders as a regular post until parent arrives (eventual consistency).
|
||||
|
||||
**Subscription batching.** Existing feed subscription (FeedScreen.kt:419–429)
|
||||
already batches missing event IDs. Extend the missing-set computation:
|
||||
|
||||
```kotlin
|
||||
val missingParentIds = displayItems.mapNotNull { item ->
|
||||
val target = (item.event as? BaseThreadedEvent)
|
||||
?.replyingToAddressOrEvent()
|
||||
?.takeUnless { it.contains(":") }
|
||||
target?.takeIf { localCache.getNoteIfExists(it)?.event == null }
|
||||
}
|
||||
```
|
||||
|
||||
Pipe these into the existing batch — no new subscription path.
|
||||
|
||||
### NoteCard Render Path
|
||||
|
||||
`QuotedNoteEmbed` signature (NoteCard.kt:488-493) — **UNCHANGED**:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun QuotedNoteEmbed(
|
||||
noteId: String,
|
||||
localCache: DesktopLocalCache?,
|
||||
onMentionClick: ((String) -> Unit)? = null,
|
||||
onNavigateToThread: ((String) -> Unit)? = null,
|
||||
)
|
||||
```
|
||||
|
||||
Recursion is impossible because `replyContext` is a `NoteCard` parameter,
|
||||
not a field of `NoteDisplayData`. When `QuotedNoteEmbed` internally calls
|
||||
`NoteCard(...)` for the parent (line 512), it doesn't pass `replyContext`
|
||||
— the default null applies and the parent renders without further embed.
|
||||
|
||||
NoteCard render path:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
displayData: NoteDisplayData,
|
||||
localCache: DesktopLocalCache?,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
replyContext: ReplyContext? = null, // NEW — default null suppresses embed recursion
|
||||
...
|
||||
) {
|
||||
Column {
|
||||
replyContext?.let { ctx ->
|
||||
if (ctx.parentNoteId != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
) {
|
||||
QuotedNoteEmbed(
|
||||
noteId = ctx.parentNoteId,
|
||||
localCache = localCache,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
)
|
||||
}
|
||||
}
|
||||
ReplyToLabel(
|
||||
parentAuthorDisplay = ctx.parentAuthorDisplay,
|
||||
onClick = { onNavigateToProfile(ctx.parentAuthorPubKey) },
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
// existing body / actions render — unchanged
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual styling.** `MaterialTheme.colorScheme.outlineVariant` (1.dp border)
|
||||
is already used in `ThreadScreen.kt:283`. Matches Android's `replyModifier`
|
||||
intent without inventing new theme keys. Rounded 8.dp corners match the
|
||||
existing card chrome of the `Loading quoted note...` placeholder.
|
||||
|
||||
### Shared Composable: `ReplyToLabel` (in commons)
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt
|
||||
@Composable
|
||||
fun ReplyToLabel(
|
||||
parentAuthorDisplay: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(Res.string.replying_to),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Text(
|
||||
text = "@$parentAuthorDisplay",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key extraction refactor decisions:
|
||||
|
||||
- **String:** `R.string.replying_to` → new `Res.string.replying_to` in
|
||||
`commons/.../composeResources/values/strings.xml`. Localized strings already
|
||||
in Android `res/values-*/strings.xml` will move to commons in a follow-up
|
||||
(out of scope for this fix — English-only commons string acceptable for v1
|
||||
per existing commons string practice).
|
||||
- **Navigation:** drop the `INav` + `routeFor` coupling; expose `onClick: () -> Unit`
|
||||
callback. Android caller passes
|
||||
`{ nav.nav(routeFor(parentAuthorUser)) }`; desktop passes
|
||||
`{ onNavigateToProfile(pubKey) }`.
|
||||
- **User lookup:** caller resolves the display name and passes it as
|
||||
`String` rather than passing a `User` and resolving inside. Keeps the
|
||||
composable pure of cache dependencies. This deliberately diverges from the
|
||||
original Android `ReplyToLabel` signature which took a `Note` — the
|
||||
refactored version is cleaner and equally functional.
|
||||
- **`ReplyInfoMention` helper (lines 145–163):** the simpler "render
|
||||
display name + emoji" logic is inlined into the new `ReplyToLabel` for
|
||||
v1; if desktop later needs the emoji-aware variant we extract that too.
|
||||
Android currently uses `ReplyInfoMention` for emoji rendering inside the
|
||||
label — for v1, the simple `@displayName` text is acceptable; Android may
|
||||
regress on inline emoji in display names until follow-up.
|
||||
|
||||
**Trade-off flagged:** the Android user briefly loses inline custom-emoji
|
||||
rendering in the "Replying to @X" line. Acceptable for this fix; raise as
|
||||
follow-up.
|
||||
|
||||
### Shared: `ReplyRenderType` enum
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyRenderType.kt
|
||||
enum class ReplyRenderType { FULL, LINE, NONE }
|
||||
```
|
||||
|
||||
Trivial extraction. Android `Text.kt` imports from commons instead of
|
||||
declaring locally.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
Each phase is independently buildable and testable. Don't move on until the
|
||||
previous phase compiles + (where applicable) `./gradlew test` passes.
|
||||
|
||||
### Phase 1 — Commons Additions
|
||||
|
||||
**Goal:** add shared `ReplyContext` data class and `ReplyToLabel` composable
|
||||
to commons + add the `replying_to` string. No behavior change yet.
|
||||
|
||||
Files (exact paths confirmed against repo):
|
||||
- `commons/src/commonMain/composeResources/values/strings.xml` — add new
|
||||
section header and key. `strings.xml` is **domain-grouped** (see existing
|
||||
`<!-- Login & Auth -->`, `<!-- Common Actions -->`, `<!-- Errors -->`,
|
||||
`<!-- Loading & Empty States -->`, `<!-- Accessibility -->` sections).
|
||||
Append:
|
||||
```xml
|
||||
<!-- Notes & Replies -->
|
||||
<string name="replying_to">replying to </string>
|
||||
```
|
||||
Trailing space matches Android original. Package for generated `Res` class
|
||||
is `com.vitorpamplona.amethyst.commons.resources` (confirmed at
|
||||
`commons/build.gradle.kts:194`).
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt` — new file with the data class + companion `from()`.
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt` — new composable. Uses `androidx.compose.foundation.layout.FlowRow` (live precedent at `commons/.../nip23LongContent/ui/editor/MetadataPanel.kt:26`) and `org.jetbrains.compose.resources.stringResource`.
|
||||
|
||||
**Not extracted:** `ReplyRenderType` enum stays in Android. Desktop only
|
||||
uses one mode (FULL); extracting just to keep Android importing from
|
||||
commons is YAGNI.
|
||||
|
||||
Verify:
|
||||
- `./gradlew :commons:compileKotlinJvm` succeeds.
|
||||
- `./gradlew :commons:compileKotlinAndroid` succeeds.
|
||||
|
||||
### Phase 2 — Desktop NoteCard Parameter
|
||||
|
||||
**Goal:** add a `replyContext: ReplyContext? = null` parameter to
|
||||
`NoteCard`. Branch render in the body. `NoteDisplayData` and
|
||||
`toNoteDisplayData` are NOT touched.
|
||||
|
||||
Files:
|
||||
- `desktopApp/.../ui/note/NoteCard.kt` —
|
||||
- Import `ReplyContext` and `ReplyToLabel` from commons.
|
||||
- Add `replyContext: ReplyContext? = null` and (if not already present)
|
||||
`localCache: DesktopLocalCache?` parameters to `NoteCard`.
|
||||
- Insert the render branch above the body (see "NoteCard Render Path"
|
||||
section above).
|
||||
|
||||
**Recursion impossibility (by construction).** `QuotedNoteEmbed` internally
|
||||
calls `NoteCard(...)` without passing `replyContext` — the default `null`
|
||||
applies, no embed. No flag, no callsite audit, no behavior change to any
|
||||
non-feed surface.
|
||||
|
||||
**Existing call sites — do they need updates?** No. They all pass the
|
||||
default `null` for `replyContext`. Bookmarks / search / quote-mentions
|
||||
continue rendering exactly as today. Reply context engages only in feed
|
||||
contexts where Phase 3 explicitly passes a non-null value.
|
||||
|
||||
Verify:
|
||||
- `./gradlew :desktopApp:compileKotlin` succeeds.
|
||||
- Existing app still renders feed identically (Phase 3 wires the actual
|
||||
detection + observation).
|
||||
|
||||
### Phase 3 — Desktop FeedScreen Wiring
|
||||
|
||||
**Goal:** FeedScreen computes `ReplyContext` per item, observes parent
|
||||
metadata for async arrival, passes context to `NoteCard`. Replies render
|
||||
with embedded parent + label.
|
||||
|
||||
Files:
|
||||
- `desktopApp/.../ui/FeedScreen.kt` —
|
||||
- In each LazyColumn item (around lines 230-260, the normal note render
|
||||
path; mirror the repost pattern at lines 128-152 for the flow
|
||||
observation):
|
||||
- Compute `replyTargetEventId = event.replyingToAddressOrEvent()?.takeUnless { it.contains(":") }`
|
||||
- If non-null, `getOrCreateNote(it)` and observe `note.flow().metadata.stateFlow.collectAsState()`
|
||||
- Compute `ReplyContext.from(event as BaseThreadedEvent, localCache)` in a `remember(event, parentMetaState?.value)` block
|
||||
- Pass the resulting `replyContext` to `NoteCard`
|
||||
- Extend the missing-event-IDs batch (lines 419–429) with reply parent IDs
|
||||
so relay subscriptions fetch them along with everything else.
|
||||
|
||||
Manual check after this phase: see Testing section.
|
||||
|
||||
### Phase 4 — Android Switchover
|
||||
|
||||
**Goal:** Android's `ReplyToLabel` definition deletes; callers use the
|
||||
shared commons version.
|
||||
|
||||
Files:
|
||||
- `amethyst/.../ui/note/ReplyInformation.kt` — delete `ReplyToLabel`
|
||||
function (lines 121–142). Keep `ReplyInfoMention` (lines 145–163) — it's
|
||||
still used elsewhere if any caller remains (grep first).
|
||||
- `amethyst/.../ui/note/types/Text.kt` —
|
||||
- In `RenderTextEvent`'s LINE branch (lines 117-121), replace the local
|
||||
`ReplyToLabel` call with the imported commons version. Resolve the
|
||||
parent author's display name at the call site (currently the Android
|
||||
version did it internally):
|
||||
```kotlin
|
||||
val parentAuthor = remember(replyingDirectlyTo) {
|
||||
replyingDirectlyTo.author?.toBestDisplayName().orEmpty()
|
||||
}
|
||||
ReplyToLabel(
|
||||
parentAuthorDisplay = parentAuthor,
|
||||
onClick = { replyingDirectlyTo.author?.let { nav.nav(routeFor(it)) } },
|
||||
)
|
||||
```
|
||||
- `amethyst/src/main/res/values/strings.xml` — remove `replying_to`
|
||||
(now lives in commons).
|
||||
- `amethyst/src/main/res/values-*/strings.xml` — leave translations until
|
||||
follow-up that migrates to commons multi-locale resources.
|
||||
|
||||
**Not changed in Phase 4:**
|
||||
- `ReplyRenderType` enum stays in `Text.kt` (no extraction; Android keeps
|
||||
using its three modes internally).
|
||||
- `ReplyNoteComposition` unchanged (still uses Android's own `NoteCompose`).
|
||||
- Custom-emoji rendering in the "Replying to @X" line via `ReplyInfoMention`:
|
||||
Android loses this momentarily. Flagged in PR body as known regression
|
||||
with follow-up.
|
||||
|
||||
Verify:
|
||||
- `./gradlew :amethyst:compileDebugKotlin` succeeds.
|
||||
- Run Android app, confirm reply rows in home feed still show "replying
|
||||
to @X".
|
||||
|
||||
### Phase 5 — Tests, Polish, Format
|
||||
|
||||
**Goal:** unit-test the new detection logic, run formatter, ready for review.
|
||||
|
||||
Files:
|
||||
- `commons/src/jvmTest/.../ReplyContextTest.kt` — new test file with cases
|
||||
exercising `ReplyContext.from(event, cache)`:
|
||||
- kind 1 with reply marker → `parentNoteId` matches the marked tag.
|
||||
- kind 1 with root + reply markers → `parentNoteId` matches reply, not
|
||||
root.
|
||||
- kind 1 single unmarked e tag → `parentNoteId` matches (positional).
|
||||
- kind 1111 with `e` tag → `parentNoteId` matches.
|
||||
- kind 1111 with `a` tag → `parentNoteId == null` (addressable).
|
||||
- Non-reply kind 1 → returns null.
|
||||
- Reply with parent NOT in cache and no NIP-22 `replyAuthor()` → returns
|
||||
null (eventual consistency — pops in on parent arrival).
|
||||
- Reply with parent in cache → `parentAuthorPubKey` matches parent's
|
||||
`pubKey`, `parentAuthorDisplay` from User metadata if loaded.
|
||||
|
||||
Test placement rationale: lives in `commons/` because that's where
|
||||
`ReplyContext` and its `from()` function live. Pattern follows existing
|
||||
commons test conventions.
|
||||
|
||||
**Skipped:** commons `ReplyToLabelTest` smoke test — testing a 2-`Text`
|
||||
composable provides no real coverage; logic lives in `ReplyContext.from()`
|
||||
which has its own test.
|
||||
|
||||
Run:
|
||||
- `./gradlew :commons:jvmTest --tests "*ReplyContext*"`
|
||||
- `./gradlew spotlessApply`
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
- `FeedScreen` LazyColumn item → reads note + parent flowSet metadata →
|
||||
recomposes on either change → calls `toNoteDisplayData(cache)` → resolves
|
||||
parent via `getNoteIfExists` → constructs `NoteDisplayData` → `NoteCard`
|
||||
branches on `replyContext` → renders `QuotedNoteEmbed` + `ReplyToLabel`.
|
||||
- Click on `ReplyToLabel` → invokes `onNavigateToProfile(parentPubKey)` →
|
||||
`DeckLayout` pushes profile column.
|
||||
- Click on `QuotedNoteEmbed` → invokes `onNavigateToThread(parentNoteId)` →
|
||||
`DeckLayout` pushes thread column (existing behavior).
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
- **Parent fetched-then-evicted from cache.** `LargeSoftCache` is a soft
|
||||
reference cache; under memory pressure the parent `Note` could be evicted
|
||||
between `getNoteIfExists` calls. Impact: `QuotedNoteEmbed` falls back to its
|
||||
"Loading quoted note..." card briefly until re-fetched. Mitigation: the
|
||||
parent flow observation re-triggers fetch on next recomposition.
|
||||
Acceptable.
|
||||
- **Reply detection wrong for thread root.** A note with only a `root`
|
||||
marker (no `reply` marker) is a reply to the root. `replyingToAddressOrEvent`
|
||||
already returns the root in that case — covered.
|
||||
|
||||
### Error & Failure Propagation
|
||||
|
||||
- `replyingToAddressOrEvent()` returns null for non-replies → `resolveReplyContext`
|
||||
returns null → no UI change. Safe.
|
||||
- Malformed `e` tag (non-hex) → `toEventIdOrNull()` returns null →
|
||||
`resolveReplyContext` returns null. Silent skip is correct.
|
||||
- Missing parent + missing tag author pubkey → `resolveReplyContext` returns
|
||||
null. Reply renders as regular post (current behavior, no regression).
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
| Surface | Reply rendering today | After this change |
|
||||
|---|---|---|
|
||||
| Desktop home feed | None | Full (this PR) |
|
||||
| Desktop hashtag / profile feeds | None | **Inherits**, since all desktop feeds funnel through `FeedScreen` + `NoteCard`. Verify in QA. |
|
||||
| Desktop thread view | Already structural | Unchanged |
|
||||
| Android home feed | Full | Unchanged (still works via re-pointed `ReplyToLabel`) |
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
(Manual; documented under Testing.)
|
||||
|
||||
1. Post a fresh reply on Android → confirm appears with embed + label on
|
||||
desktop home feed.
|
||||
2. Reply via desktop to a note from another account → confirm embed appears
|
||||
in own feed after relay round-trip.
|
||||
3. NIP-22 kind 1111 reply on an article (kind 30023) → confirm
|
||||
addressable-parent path renders label only (no embed in v1).
|
||||
4. Scroll feed fast, parent loads after reply visible → confirm embed
|
||||
"pops in" via recomposition without scroll-position jump.
|
||||
5. Reposted reply (someone reposts another's reply) → confirm we don't
|
||||
recursively double-wrap (only the outermost repost wrapper renders; inner
|
||||
reply context not re-embedded inside the repost). v1 acceptable behavior.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
- [ ] A reply note in the desktop home feed renders the embedded parent
|
||||
card directly above the reply body.
|
||||
- [ ] A reply note shows "replying to @displayName" below the parent embed
|
||||
and above the reply body.
|
||||
- [ ] Clicking the "@displayName" opens the parent author's profile.
|
||||
- [ ] Clicking the embedded parent opens the parent's thread.
|
||||
- [ ] If the parent event is not yet in cache, the label is rendered with
|
||||
the parent author's display name (or truncated hex if metadata also
|
||||
missing). No empty quoted card is rendered.
|
||||
- [ ] When the parent event arrives via relay subscription, the embed
|
||||
appears via recomposition without manual refresh.
|
||||
- [ ] NIP-22 generic comments (kind 1111) get the same treatment as
|
||||
NIP-10 kind-1 replies.
|
||||
- [ ] Non-reply notes render identically to today (no regression).
|
||||
- [ ] Reposts continue to render identically to today.
|
||||
- [ ] Android home feed reply rendering still works (no regression after
|
||||
switchover to shared `ReplyToLabel`).
|
||||
|
||||
### Non-Functional
|
||||
|
||||
- [ ] No measurable feed scroll-frame regression (visual sniff test on a
|
||||
~500-item feed).
|
||||
- [ ] No new unbounded subscriptions — parent fetches use the existing
|
||||
batched missing-event-ID mechanism.
|
||||
- [ ] Quote depth strictly = 1 (parent embed does not itself embed a
|
||||
grandparent). Verified by unit test.
|
||||
|
||||
### Quality
|
||||
|
||||
- [ ] `./gradlew :commons:build` passes.
|
||||
- [ ] `./gradlew :desktopApp:test` passes (including new tests).
|
||||
- [ ] `./gradlew :amethyst:compileDebugKotlin` passes.
|
||||
- [ ] `./gradlew spotlessApply` applied; no diff after.
|
||||
- [ ] Manual UI checklist (above) passes on a real account.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit
|
||||
|
||||
```bash
|
||||
./gradlew :desktopApp:test --tests "*EventExtensionsReplyContext*"
|
||||
./gradlew :commons:jvmTest --tests "*ReplyToLabel*"
|
||||
```
|
||||
|
||||
### Manual UI Checklist
|
||||
|
||||
1. Launch desktop app: `./gradlew :desktopApp:run`
|
||||
2. Open home feed, scroll to a thread with replies.
|
||||
3. Verify reply rows show parent embed + "replying to @X".
|
||||
4. Click "@X" → profile opens in new deck column.
|
||||
5. Click parent embed → thread opens in new deck column.
|
||||
6. Force-fetch a reply whose parent is NOT yet cached (e.g. follow
|
||||
someone whose reply targets an old root):
|
||||
- confirm label shows immediately.
|
||||
- confirm embed appears within a few seconds (after relay returns).
|
||||
7. Open a feed of an account that uses kind 1111 (NIP-22) replies →
|
||||
confirm same treatment.
|
||||
8. Compare against Android side-by-side for the same account/relays.
|
||||
9. Confirm a long thread doesn't recursively show grandparent (1-level
|
||||
cap).
|
||||
10. Confirm reposts don't visually regress.
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
- **No new third-party deps.**
|
||||
- **Depends on quartz's `replyingToAddressOrEvent()` being correct** — this
|
||||
is already battle-tested on Android. Low risk.
|
||||
- **Risk: Android emoji-in-reply-label regression** for users with custom
|
||||
emoji in display names. Documented trade-off; follow-up to extract
|
||||
`ReplyInfoMention` if regression confirmed.
|
||||
- **Risk: `QuotedNoteEmbed` signature mismatch** — implementation phase
|
||||
may need to refactor it to accept `NoteDisplayData`. Contained.
|
||||
- **Risk: Address-based parents (`a` tag) for kind 30023 articles** — the
|
||||
embed render path may not handle non-kind-1 parents gracefully. Mitigation:
|
||||
when `ReplyContext.parentNoteId == null` (addressable), render label only
|
||||
(skip the `QuotedNoteEmbed` branch). Spec'd in NoteCard render path.
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm:**
|
||||
[docs/brainstorms/2026-06-05-desktop-feed-reply-context-brainstorm.md](../brainstorms/2026-06-05-desktop-feed-reply-context-brainstorm.md)
|
||||
— key decisions carried forward: FULL render mode, extract to commons now,
|
||||
NIP-22 same treatment, 1-level depth, pre-compute in
|
||||
`Event.toNoteDisplayData`.
|
||||
|
||||
### Internal
|
||||
|
||||
- Detection: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt:73-84`
|
||||
- Android render: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt:82-124`
|
||||
- Android `ReplyToLabel`: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt:121-142`
|
||||
- Android `ReplyNoteComposition` (NOT extracted — for reference only):
|
||||
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt:1491-1508`
|
||||
- Desktop `NoteCard` + `NoteDisplayData`:
|
||||
`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt:81-88, 488-535`
|
||||
- Desktop converter: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt:32-53`
|
||||
- Desktop cache: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:564-571`
|
||||
- Repost pattern (blueprint): `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt:128-197`
|
||||
- Best-name resolution: `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt:90`
|
||||
- Compose Multiplatform string pattern: `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt:31-38`
|
||||
|
||||
### Worktree
|
||||
|
||||
- Path: `../AmethystMultiplatform-feed-reply-context`
|
||||
- Branch: `fix/desktop-feed-reply-context`
|
||||
- Base: `origin/main` @ `0874706b3`
|
||||
|
||||
## Unanswered Questions
|
||||
|
||||
### Resolved by deepen-plan research
|
||||
|
||||
- ✅ **`QuotedNoteEmbed` signature** — takes `(noteId: String, localCache, onMentionClick?, onNavigateToThread?)`. Handles loading state internally. We just pass `noteId`.
|
||||
- ✅ **`replyingToAddressOrEvent()` return** — flat `String?`. Disambiguate event-id vs address by `":" in raw`.
|
||||
- ✅ **NIP-22 addressable parent** — v1 = **label-only** (no embed). `QuotedNoteEmbed` doesn't accept `Address`. Follow-up.
|
||||
- ✅ **Visual border** — `MaterialTheme.colorScheme.outlineVariant` 1.dp + `RoundedCornerShape(8.dp)`. Already used in `ThreadScreen.kt:283`.
|
||||
- ✅ **Translations** — leave Android translations until follow-up commons multi-locale consolidation. English-only key in commons `strings.xml`.
|
||||
- ✅ **Reposted-reply behavior** — FeedScreen extracts inner note then renders full `NoteCard` on it. Our new reply-context auto-engages on the inner card. Acceptable; no extra work.
|
||||
- ✅ **Hashtag / profile / bookmarks / search feeds** — all use `Event.toNoteDisplayData(localCache)` (9 verified call sites). Reply context auto-engages everywhere with no plumbing.
|
||||
|
||||
### Still open — flagged for implementation phase
|
||||
|
||||
- ⚠️ **Inline custom-emoji in "Replying to @X"** — Android currently uses `ReplyInfoMention` (lines 145-163) which renders emoji-aware display names via `CreateTextWithEmoji`. Our extracted `ReplyToLabel` takes a plain `String` for v1, losing emoji on Android. Trade-off: simpler API, faster ship. If regression complaints arrive, follow-up adds an emoji-aware overload.
|
||||
- ⚠️ **`Note.flow()` performance with many feed items** — each LazyColumn item now creates and `collectAsState`s an extra StateFlow when it's a reply. For a 500-item feed where 50% are replies, that's ~250 extra collectors. Repost code already does this so it's the established pattern, but profile if scroll regresses.
|
||||
- ⚠️ **Parent author availability when not in cache and event is not NIP-22** — `ReplyContext.from()` returns null. Reply renders as a regular post until the parent event arrives via subscription, then recomposition picks it up. UX impact: brief moment where a reply looks like a regular post. Acceptable (better than guessing the wrong author from a `p` tag).
|
||||
Reference in New Issue
Block a user