diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
index 18fd469014..13ce983ce6 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt
index ce5eb75ba0..4ca9f66c3e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt
@@ -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) {
diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml
index bccf1e297c..4b47a1e7de 100644
--- a/commons/src/commonMain/composeResources/values/strings.xml
+++ b/commons/src/commonMain/composeResources/values/strings.xml
@@ -54,4 +54,7 @@
User avatar
Navigate
+
+
+ replying to
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt
new file mode 100644
index 0000000000..92ea646504
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt
@@ -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)
+ }
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt
new file mode 100644
index 0000000000..2f9522c942
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt
@@ -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),
+ )
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt
new file mode 100644
index 0000000000..6226acc97c
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt
@@ -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,
+ private val users: Map = 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
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
index b4269a7745..a035856ab8 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
@@ -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(
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
index 12eac87cf2..6ea15e0d22 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
@@ -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(),
diff --git a/docs/plans/2026-06-05-fix-desktop-feed-reply-context-plan.md b/docs/plans/2026-06-05-fix-desktop-feed-reply-context-plan.md
new file mode 100644
index 0000000000..bc91fad3dc
--- /dev/null
+++ b/docs/plans/2026-06-05-fix-desktop-feed-reply-context-plan.md
@@ -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 `` 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 ``
+ 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) │ │
+│ │ │ │
+│ │ } │ │
+│ └───────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+ │
+ │ 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`.
+
+```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
+ ``, ``, ``,
+ ``, `` sections).
+ Append:
+ ```xml
+
+ replying to
+ ```
+ 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).