Files
amethyst/docs/plans/2026-06-05-fix-desktop-feed-reply-context-plan.md
T
nrobi144andClaude Opus 4.7 ad8556a82c 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>
2026-06-06 15:27:10 +03:00

815 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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
128197). 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:488535) 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:128152, 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:419429)
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 145163):** 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 419429) 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 121142). Keep `ReplyInfoMention` (lines 145163) — 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).