Two surfaces render zap-receipt messages, each with a different
treatment to suit its constraints.
Engagement-drawer zap row (mini-post)
- Now renders the message through the same `RichContent` pipeline a
regular post body uses. Body text, plain links, hashtags, profile
/ note mentions, and inline images all render the way they would
in a feed post. The row grows vertically rather than truncating —
long zapvertising payloads stay readable.
- Layout: avatar (top-aligned, 30dp) + right-side column (display
name + private icon + bolt + sats on top; RichContent body below).
- `eventRepo` is plumbed from `ReactionDetailsSection` into `ZapRow`
so RichContent can resolve profile mentions.
Top-zap banner above the action bar
- Single-line preview. Image URLs collapse to the literal token
`[image]` (helpers in `ui/util/ZapMessageImage.kt`) so the sats
amount stays visible. Surrounding text is preserved
("nice post [image]"). No image is loaded in the banner — the
drawer handles that.
Cross-platform: `ZAP_IMAGE_PARITY.md` documents the contract — banner
uses URL-collapse, drawer uses the full post-body renderer; iOS port
checklist + visual test included.
7.9 KiB
Zap Message Rich Content — Cross-Platform Parity
Companion to WALLET_PARITY.md and LIGHT_MODE_COLOR_PARITY.md. This
doc captures the contract for rendering rich zap-receipt messages
(body text, links, hashtags, inline images) so iOS and Android look
the same.
Android lands this in feat/zap-with-image. iOS should mirror.
1. Motivation
Zap messages are a free-text field and people use them for everything
from "thanks!" to full promotional payloads with body text, links, and
attached images ("zapvertising"). Rendering them as single-line
truncated strings — the original behavior — destroyed the content:
the body got chopped to ... tes…, links were unclickable, and
attached images were only sometimes detected.
Spec: the engagement-drawer zap row now renders the message body
through the same rich-content pipeline as a regular post body. The
post-card top-zap banner stays a one-line preview with image URLs
collapsed to [image].
2. Detection contract (top-zap banner only)
The post-card top-zap banner needs a single-line preview. To keep the
sats amount visible, image URLs inside the message are replaced with
the literal token [image]. This is the only surface that
performs URL detection — the drawer uses the full rich-content
renderer instead.
A URL is treated as an inline image if and only if its path ends in one of:
jpg, jpeg, png, gif, webp, heic, heif, bmp, svg
(case-insensitive, query/fragment stripped before extension check).
Same extension set the rest of the app already uses for inline images in regular post content — keep it identical so behavior is consistent. Only the first image URL is collapsed; surrounding text is preserved.
3. Surfaces
There are exactly two places where zap messages render today, and each gets a specific treatment:
3.1 Top-zap banner (above the post action bar)
Single-line preview. Trade: room is tight, the sats amount must stay
visible. So when the message contains an image URL, replace the URL
with the literal token [image] and keep the surrounding text.
Examples:
| Message | Banner shows |
|---|---|
https://i.nostr.build/abc.gif |
[image] |
nice post https://i.nostr.build/abc.gif |
nice post [image] |
https://i.nostr.build/abc.gif love it |
[image] love it |
no image here, just text |
no image here, just text (unchanged) |
No image is loaded on the banner — it's a one-line preview.
3.2 Engagement drawer zap row
Renders the zap as a mini-post. Layout, top to bottom:
- Header row — avatar (30dp, top-aligned) on the left, then a
weighted column on the right:
- Display-name line (semibold labelMedium, left), private-zap icon (if applicable), bolt + sats amount (orange, right-aligned).
- Body (only when
message.isNotBlank()) — full post-content pipeline. Same renderer used for regular post bodies (Android:RichContent; iOS: equivalentPostBodyRenderer/ whatever you use for kind-1 content). Handles inline text, hashtags, profile/ note mentions, inline images / videos / embeds, and OG-style social preview cards for plain links (the renderer fetches og:image / og:title / og:siteName / og:description on demand and renders a tappable card). Caching applies the same way as for links inside post bodies, so a URL pasted in a zap message and the same URL in a regular post share preview data.
The body inherits the post-body callbacks the caller already plumbs
(onProfileClick, onNoteClick, onHashtagClick, eventRepo for
profile resolution), so a hashtag in a zap message is just as
clickable as one in the post above it. No special-case URL detection
inside the drawer — the rich-content renderer already detects images
and renders them inline at the right position in the body, which is
exactly what zapvertising payloads need.
Long messages are NOT truncated. The drawer grows vertically. Trade- off: occasionally tall zap rows when someone pastes a 500-char manifesto. Worth it — truncation destroys the value of the message.
4. Android implementation references
| Concern | File | Symbol |
|---|---|---|
| Banner detection + preview-text helpers | app/src/main/kotlin/com/wisp/app/ui/util/ZapMessageImage.kt |
firstImageUrl, previewText |
| Top-zap banner integration | app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt |
TopZapperBanner (message line) |
| Engagement-drawer row integration | app/src/main/kotlin/com/wisp/app/ui/component/ReactionDetailsSection.kt |
ZapRow |
| Shared post-body renderer used by ZapRow | app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt |
RichContent |
ZapMessageImage.kt is ~30 lines of plain Kotlin — only used by the
banner now. The drawer delegates to RichContent, which is the same
component a normal feed post renders its body through, so the iOS
port is mostly "pass the message string through your existing post-
body renderer" rather than writing detection from scratch.
5. iOS port checklist
- Add
ZapMessagehelper (or extension) exposingfirstImageURL(in:)andpreviewText(for:)with the extension set from §2. Only used by the banner. - In the top-zap banner view, swap the raw message string for
ZapMessage.previewText(for: message). - Rewrite the engagement-drawer zap row as a mini-post:
- Header row: 30pt avatar (top-aligned) + display name (semi-
bold labelMedium) on the left, optional private icon + bolt
icon + sats amount (orange) on the right.
- Body: pass
messageto your existing post-body renderer (the same component that renders kind-1 content in the feed). Inherits its hashtag / mention / link / inline-image handling. - Do not truncate the body. Let it wrap vertically. - Plumb the existing post-body callbacks
(
onProfileClick,onNoteClick,onHashtagClick, eventRepo equivalent) into the zap row so links in zap messages are as interactive as links in a regular post.
5.1 Visual test
- Zap any post with
"check out https://my-site.com #bitcoin"— the engagement drawer renders the message with an OG preview card for the URL (image / title / domain) and the#bitcoinhashtag styled like in a post body. - Zap with a message containing only an image URL — the drawer
renders the inline image (no extra text), banner shows
[image]. - Zap with a long body (e.g. 400 chars) — the drawer row grows vertically, nothing is truncated.
- Zap with a body that includes a
nostr:nevent1…reference — the referenced note renders as a quoted card inside the zap row (or whatever your post-body renderer does for note refs). - Banner check: top-zap banner stays single-line with the sats amount visible regardless of message length.
- Tap a social-preview card in the drawer → opens the URL in a browser (same handler as link previews in regular posts).
6. Locked decisions
| Decision | Choice | Why |
|---|---|---|
| Drawer renderer | Full post-body renderer | Zapvertising payloads (body + links + media) deserve the same rendering as a regular post. Custom detection lost too much. |
| Drawer body truncation | None — let it wrap | Truncation killed the value of the message. Drawer height is acceptable trade. |
| Image-detection extensions (banner only) | Same set as in-post images | Behavior parity with the rest of the app. |
| Single-line banner preview | [image] token |
Sats amount must stay readable on the top-zap banner. |
| Image detection scope | Banner only | Drawer's post-body renderer already handles inline images at the right position. |
| Multiple image URLs in the banner | Only the first collapses to [image] |
Edge case; secondary URLs stay as text. |
| Animated GIFs | Loaded as-is via the post-body renderer | Coil on Android already animates GIFs; iOS post-body renderer should too. |
| Loading / failed states | Default platform behavior | Don't bake a placeholder — let the renderer fall through. |