Files
com.wisp.app/ZAP_IMAGE_PARITY.md
The Daniel 71d4228601 feat(zap): render image URLs inline in zap drawer, collapse to [image] on top banner
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.
2026-05-22 12:20:39 -04:00

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: equivalent PostBodyRenderer / 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 ZapMessage helper (or extension) exposing firstImageURL(in:) and previewText(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 message to 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

  1. 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 #bitcoin hashtag styled like in a post body.
  2. Zap with a message containing only an image URL — the drawer renders the inline image (no extra text), banner shows [image].
  3. Zap with a long body (e.g. 400 chars) — the drawer row grows vertically, nothing is truncated.
  4. 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).
  5. Banner check: top-zap banner stays single-line with the sats amount visible regardless of message length.
  6. 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.