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:
nrobi144
2026-06-06 15:27:10 +03:00
co-authored by Claude Opus 4.7
parent 5a5a4a9c27
commit ad8556a82c
9 changed files with 1155 additions and 32 deletions
@@ -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>
@@ -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)
}
}
}
@@ -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),
)
}
}
@@ -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).