mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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>
815 lines
41 KiB
Markdown
815 lines
41 KiB
Markdown
---
|
||
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).
|