- rendering/: RenderedEvent + EventRendererRegistry with renderers for kinds 0/1/3/5/6/7/16/1111/9735/10002/10050/30023, a default for everything else, the BodySpans rich-text post-processor over richtext/RichTextParser, and json/JsonEventFormatter (stable snake_case keys). Plan 2026-04-21-event-renderer moves to in-progress. - model/EventThreadTree: NIP-10/NIP-22 parent/root + depth-first layout over raw events (the cache-free sibling of ThreadAssembler). Desktop's private findReplyToId now delegates to it. - moderation/notifications/NotificationItem: extracted from Desktop's NotificationsScreen (classify + effectiveAuthorPubKey); a kind:1 is a reply only when it actually answers a note, not when it merely cites one. - actions: QuoteActions (NIP-18 quote), DeletionActions (NIP-09, own events, 200 targets per kind:5; Account.delete shares the constant), ContentTags (p/q/t/r tags derived from typed text, like the composer), and ReplyActions.reply/commentOn (NIP-10 for kind:1, NIP-22 otherwise). ReplyActions.repliesAsComment is now the one rule the composer and amy use for answering top-level Amethyst posts with a kind:1111. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmDZGXB7n5dLuegMARB1Rh
7.3 KiB
title, type, status, date, owner, consumers
| title | type | status | date | owner | consumers |
|---|---|---|---|---|---|
| feat(commons): cross-platform event renderer | feat | in-progress | 2026-04-21 | commons | cli, desktopApp, amethyst |
feat(commons): cross-platform event renderer
Status: in progress (2026-10-06). Migration steps 1, 2 (JSON half) and 4 are in:
commons/.../rendering/holdsRenderedEvent,EventRenderer/EventRendererRegistry(+DefaultRenderer),RenderContext, the sharedBodySpanspost-processor andjson/JsonEventFormatter, with renderers for kinds 0, 1, 3, 5, 6, 7, 16, 1111, 9735, 10002, 10050 and 30023. amy consumes it (notes show,notes thread,notes feed,notifications). Remaining: the Compose formatter (it belongs incommonsUI, not here — see "Decisions taken") and switching Android/Desktop note rendering over to it (step 3).
Decisions taken while implementing
EventRendereris not generic. The registry is keyed by kind and erases the type anyway; each renderer casts and falls back to the default renderer on a mismatch, so a malformed event never throws.RenderContextis pre-resolved (the open question below): it carries a pubkey →UserMetadatamap the caller fills. Renderers stay pure and synchronous.- Rich text is a shared post-processor (
BodySpans), built on the existingrichtext/RichTextParserthe Android/Desktop viewers already use — not a second parser. Every text-bearing kind gets hashtag / mention / link / media detection. - Gift wraps are not rendered specially: callers unwrap first, then render the inner event; an outer kind:1059 goes through the default renderer.
- Kind-specific data lives in a sealed
RenderedDetailsinstead of more nullable top-level fields. - The Compose formatter goes to
commonsUI. Since the 2026-09-12 split,commonscarries no Compose UI, so the "Compose lock-in" risk below is resolved by the module boundary:commons/rendering(pure) +commonsUI/rendering(Compose).
Overview
Introduce a commons/commonMain/.../rendering/ subsystem that turns a
quartz Event into a structured, UI-agnostic RenderedEvent value.
Three consumers plug into the same output:
- Amy (
cli/) — serialisesRenderedEventto stable JSON. - Desktop (
desktopApp/) — feedsRenderedEventinto Compose views. - Amethyst Android (
amethyst/) — same, but Android-native layouts.
This is a commons/-owned subsystem because it's consumed by three
modules. Amy drives the need first (it can't read any event it can't
render), but the interface isn't Amy-specific.
Problem
Today every surface that displays a Nostr event re-parses the raw
event structure. Kind:1 rendering, mention resolution, thread roots,
image extraction, hashtag indexing — each lives inline in Compose
components in amethyst/ui/note/. Amy cannot share any of it because
it can't depend on Compose or on amethyst/.
Consequences:
amy note showcannot exist without either (a) duplicating the parsing or (b) extracting it somewherecli/can call.- Option (b) is the right answer and also fixes Desktop, which is currently duplicating Android's parsing in a slightly different way.
Design
// commons/commonMain/.../rendering/EventRenderer.kt
interface EventRenderer<E : Event> {
fun render(event: E, ctx: RenderContext): RenderedEvent
}
data class RenderedEvent(
val kind: Int,
val eventId: HexKey,
val author: AuthorRef,
val createdAt: Long,
val title: String?,
val summary: String?,
val body: List<BodySpan>, // text, mention, link, image, video, code
val mentions: List<MentionRef>,
val media: List<MediaRef>,
val replyTo: EventRef?,
val root: EventRef?,
val raw: Event, // escape hatch
)
object EventRendererRegistry {
fun register(kind: Int, renderer: EventRenderer<*>)
fun render(event: Event, ctx: RenderContext): RenderedEvent
}
- Registry keyed by
event.kind. Default renderer dumps raw tags + content so nothing is ever un-renderable. - Per-kind specialised renderers cover what Amethyst displays specially: 0, 1, 3, 6, 7, 9, 445, 1059 (unwrapped), 10002, 10050, 30023, 30043, 30311 …
RenderContextcarries anything kind-specific needs that's not on the event itself: a pubkey → metadata lookup, a NIP-05 resolver, a media-preview cache, etc. Default implementation incommons/with test-friendly no-op behaviour.- Formatters are separate:
JsonEventFormatter.format(rendered): String— Amy's output.@Composable Render(rendered)— Desktop + Android.TextEventFormatter.format(rendered): String— optional human-readable terminal mode (amy note show EID --format text).
Migration strategy
- Land the interface and
RenderedEventdata model. No consumers. - Port a single kind (kind:1) end-to-end:
- Renderer in
commons/commonMain/.../rendering/. - JSON formatter in
commons/commonMain/.../rendering/json/. - Compose formatter in
commons/commonMain/.../rendering/compose/. - Unit tests in
commonTest.
- Renderer in
- Desktop and Amethyst switch their kind:1 path to the new renderer. Delete the old inline parsing.
- Repeat for kind:0, kind:7, kind:3, kind:6, kind:10002, kind:10050 (this unblocks Amy's feed commands).
- Then the long tail.
Each step is small and reversible. Each step deletes duplicated parsing somewhere.
Consumer touch-points
- Amy —
cli/commands/NoteCommands.kt#show:val rendered = EventRendererRegistry.render(event, ctx.renderCtx) Json.writeLine(JsonEventFormatter.toMap(rendered)) - Desktop — replace ad-hoc parsing in
desktopApp/.../note/*withEventRendererRegistry.render(event, renderCtx)+Render(rendered). - Android — same swap in
amethyst/ui/note/*.
Test strategy
commonTest: golden tests per kind. A fixture event → a fixtureRenderedEvent. Same fixtures feed both formatters.- JSON formatter tests become Amy's snapshot tests for free.
Open questions
RenderContextscope: should async lookups (pubkey metadata) be inside the renderer or pre-resolved by the caller? Leaning pre- resolved — renderers stay pure, caller decides hydration policy.- Do we render gift-wraps at all, or only their unwrapped inner
events? Leaning: unwrap first in
quartz/commons, then render the inner event. Outer kind:1059 gets a trivial default renderer. BodySpangranularity: does rich-text parsing (hashtag, mention, link detection) live inside the kind-specific renderer or in a shared post-processor? Leaning post-processor so every text-bearing kind gets it for free.
Risks
- Scope creep: this is a new subsystem. Keep the first PR tiny (interface + default renderer only, zero consumers).
- Compose lock-in: the
@Composable Rendersurface must live incommons/commonMain/to avoid forcing Amy to see Compose. Verify that's possible with Compose Multiplatform 1.10.3 — if not, split intocommons-render-core(pure Kotlin) +commons-render-compose.
Out of scope
- Event authoring / composition (new-post editor). Renderer is read-only.
- Relay-subscription plumbing. Renderer takes an already-received
Event, not a filter. - Media loading (image download, video playback). Renderer emits
MediaRef; the consumer decides what to do with it.