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>
41 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| Desktop Feed — Reply Context Rendering | fix | active | 2026-06-05 | 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 byCommentEvent(kind 1111). One polymorphic call covers both, no caller-side branching. - Return type —
replyingToAddressOrEvent(): String?is a flat string. Disambiguate event-id vs address byraw.contains(":"). QuotedNoteEmbedis 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.
QuotedNoteEmbeddoesn't acceptAddress; embed is follow-up. - Existing styling —
MaterialTheme.colorScheme.outlineVariant(1.dp border, used inThreadScreen.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. Packagecom.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 callsNoteCardon it. Phase 3 wiring passes a freshreplyContextfor 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:
ReplyContextmoves tocommons/commonMain/(architect's blocker). Pure protocol→display data with no platform deps; if left in desktopApp, Android re-implements when it extracts.- Drop the
withReplyContext: Booleanflag (simplicity). The flag existed only to break recursion. Instead: keepNoteDisplayDatastrictly display-only and passreplyContext: ReplyContext?as a separate parameter toNoteCard.QuotedNoteEmbedcallsNoteCardwithout areplyContext(default null) — recursion impossible by construction. NotoNoteDisplayDatacallsite audit, no behavior surprise for bookmarks / search / quote-mentions. - Drop
ReplyRenderTypeextraction (simplicity). Desktop only uses FULL; Android's LINE/NONE branches stay inamethyst/. Enum stays where it is. - Simplify
parentAuthorHint()— drop the brittle last-p-tag heuristic. Use only:CommentEvent.replyAuthor()for NIP-22, OR- Read
parent.pubKeyfrom cache once parent loads. If neither resolves: omit the label (don't show "Replying to @unknown"). Eventual consistency wins over speculative guesses.
strings.xmlsection header — add new<!-- Notes & Replies -->group; the file is domain-grouped, not alphabetized.- Skip the commons
ReplyToLabelTestsmoke test — testing a two-Textcomposable 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
ReplyContextinEvent.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:
- 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.
- Lost author cue. No indication who is being replied to — feed user can't tell from a glance whether they care.
- 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:
- Detection — reuse
BaseThreadedEvent.replyingToAddressOrEvent()fromquartz/(already KMP-common, already covers both NIP-10 and NIP-22). No new code needed inquartz/. - Shared UI — extract
ReplyRenderTypeenum andReplyToLabelcomposable fromamethyst/tocommons/commonMain/. Refactor away Android-only deps (R.string→Res.string,nav.nav(routeFor(...))→ click callback). - Desktop wiring — extend
NoteDisplayDatawith an optionalreplyContext: ReplyContext?field, populate it insideEvent.toNoteDisplayData(cache), and render it fromNoteCardusing the sharedReplyToLabel+ the existing desktopQuotedNoteEmbed()(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:
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 checkparentNoteId == 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-evaluatesfrom()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>.
// 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.
getOrCreateNotealways returns aNote— placeholder withevent = nullif not yet fetched. TheNoteFlowSet.metadatastill emits when the event later arrives (verified — same pattern reposts use today).remember(event, parentMetaState?.value)invalidates thereplyContextcomputation 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:
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:
@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:
@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)
// 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→ newRes.string.replying_toincommons/.../composeResources/values/strings.xml. Localized strings already in Androidres/values-*/strings.xmlwill 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+routeForcoupling; exposeonClick: () -> Unitcallback. Android caller passes{ nav.nav(routeFor(parentAuthorUser)) }; desktop passes{ onNavigateToProfile(pubKey) }. - User lookup: caller resolves the display name and passes it as
Stringrather than passing aUserand resolving inside. Keeps the composable pure of cache dependencies. This deliberately diverges from the original AndroidReplyToLabelsignature which took aNote— the refactored version is cleaner and equally functional. ReplyInfoMentionhelper (lines 145–163): the simpler "render display name + emoji" logic is inlined into the newReplyToLabelfor v1; if desktop later needs the emoji-aware variant we extract that too. Android currently usesReplyInfoMentionfor emoji rendering inside the label — for v1, the simple@displayNametext 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
// 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.xmlis domain-grouped (see existing<!-- Login & Auth -->,<!-- Common Actions -->,<!-- Errors -->,<!-- Loading & Empty States -->,<!-- Accessibility -->sections). Append:Trailing space matches Android original. Package for generated<!-- Notes & Replies --> <string name="replying_to">replying to </string>Resclass iscom.vitorpamplona.amethyst.commons.resources(confirmed atcommons/build.gradle.kts:194).commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt— new file with the data class + companionfrom().commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt— new composable. Usesandroidx.compose.foundation.layout.FlowRow(live precedent atcommons/.../nip23LongContent/ui/editor/MetadataPanel.kt:26) andorg.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:compileKotlinJvmsucceeds../gradlew :commons:compileKotlinAndroidsucceeds.
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
ReplyContextandReplyToLabelfrom commons. - Add
replyContext: ReplyContext? = nulland (if not already present)localCache: DesktopLocalCache?parameters toNoteCard. - Insert the render branch above the body (see "NoteCard Render Path" section above).
- Import
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:compileKotlinsucceeds.- 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 observenote.flow().metadata.stateFlow.collectAsState() - Compute
ReplyContext.from(event as BaseThreadedEvent, localCache)in aremember(event, parentMetaState?.value)block - Pass the resulting
replyContexttoNoteCard
- Compute
- Extend the missing-event-IDs batch (lines 419–429) with reply parent IDs so relay subscriptions fetch them along with everything else.
- 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):
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— deleteReplyToLabelfunction (lines 121–142). KeepReplyInfoMention(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 localReplyToLabelcall with the imported commons version. Resolve the parent author's display name at the call site (currently the Android version did it internally):val parentAuthor = remember(replyingDirectlyTo) { replyingDirectlyTo.author?.toBestDisplayName().orEmpty() } ReplyToLabel( parentAuthorDisplay = parentAuthor, onClick = { replyingDirectlyTo.author?.let { nav.nav(routeFor(it)) } }, )
- In
amethyst/src/main/res/values/strings.xml— removereplying_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:
ReplyRenderTypeenum stays inText.kt(no extraction; Android keeps using its three modes internally).ReplyNoteCompositionunchanged (still uses Android's ownNoteCompose).- 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:compileDebugKotlinsucceeds.- 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 exercisingReplyContext.from(event, cache):- kind 1 with reply marker →
parentNoteIdmatches the marked tag. - kind 1 with root + reply markers →
parentNoteIdmatches reply, not root. - kind 1 single unmarked e tag →
parentNoteIdmatches (positional). - kind 1111 with
etag →parentNoteIdmatches. - kind 1111 with
atag →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 →
parentAuthorPubKeymatches parent'spubKey,parentAuthorDisplayfrom User metadata if loaded.
- kind 1 with reply marker →
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
FeedScreenLazyColumn item → reads note + parent flowSet metadata → recomposes on either change → callstoNoteDisplayData(cache)→ resolves parent viagetNoteIfExists→ constructsNoteDisplayData→NoteCardbranches onreplyContext→ rendersQuotedNoteEmbed+ReplyToLabel.- Click on
ReplyToLabel→ invokesonNavigateToProfile(parentPubKey)→DeckLayoutpushes profile column. - Click on
QuotedNoteEmbed→ invokesonNavigateToThread(parentNoteId)→DeckLayoutpushes thread column (existing behavior).
State Lifecycle Risks
- Parent fetched-then-evicted from cache.
LargeSoftCacheis a soft reference cache; under memory pressure the parentNotecould be evicted betweengetNoteIfExistscalls. Impact:QuotedNoteEmbedfalls 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
rootmarker (noreplymarker) is a reply to the root.replyingToAddressOrEventalready returns the root in that case — covered.
Error & Failure Propagation
replyingToAddressOrEvent()returns null for non-replies →resolveReplyContextreturns null → no UI change. Safe.- Malformed
etag (non-hex) →toEventIdOrNull()returns null →resolveReplyContextreturns null. Silent skip is correct. - Missing parent + missing tag author pubkey →
resolveReplyContextreturns 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.)
- Post a fresh reply on Android → confirm appears with embed + label on desktop home feed.
- Reply via desktop to a note from another account → confirm embed appears in own feed after relay round-trip.
- NIP-22 kind 1111 reply on an article (kind 30023) → confirm addressable-parent path renders label only (no embed in v1).
- Scroll feed fast, parent loads after reply visible → confirm embed "pops in" via recomposition without scroll-position jump.
- 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:buildpasses../gradlew :desktopApp:testpasses (including new tests)../gradlew :amethyst:compileDebugKotlinpasses../gradlew spotlessApplyapplied; no diff after.- Manual UI checklist (above) passes on a real account.
Testing
Unit
./gradlew :desktopApp:test --tests "*EventExtensionsReplyContext*"
./gradlew :commons:jvmTest --tests "*ReplyToLabel*"
Manual UI Checklist
- Launch desktop app:
./gradlew :desktopApp:run - Open home feed, scroll to a thread with replies.
- Verify reply rows show parent embed + "replying to @X".
- Click "@X" → profile opens in new deck column.
- Click parent embed → thread opens in new deck column.
- 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).
- Open a feed of an account that uses kind 1111 (NIP-22) replies → confirm same treatment.
- Compare against Android side-by-side for the same account/relays.
- Confirm a long thread doesn't recursively show grandparent (1-level cap).
- 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
ReplyInfoMentionif regression confirmed. - Risk:
QuotedNoteEmbedsignature mismatch — implementation phase may need to refactor it to acceptNoteDisplayData. Contained. - Risk: Address-based parents (
atag) for kind 30023 articles — the embed render path may not handle non-kind-1 parents gracefully. Mitigation: whenReplyContext.parentNoteId == null(addressable), render label only (skip theQuotedNoteEmbedbranch). Spec'd in NoteCard render path.
Sources & References
Origin
- Brainstorm:
docs/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
- ✅
QuotedNoteEmbedsignature — takes(noteId: String, localCache, onMentionClick?, onNavigateToThread?). Handles loading state internally. We just passnoteId. - ✅
replyingToAddressOrEvent()return — flatString?. Disambiguate event-id vs address by":" in raw. - ✅ NIP-22 addressable parent — v1 = label-only (no embed).
QuotedNoteEmbeddoesn't acceptAddress. Follow-up. - ✅ Visual border —
MaterialTheme.colorScheme.outlineVariant1.dp +RoundedCornerShape(8.dp). Already used inThreadScreen.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
NoteCardon 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 viaCreateTextWithEmoji. Our extractedReplyToLabeltakes a plainStringfor 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 andcollectAsStates 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 aptag).