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.
This commit is contained in:
The Daniel
2026-05-22 12:20:39 -04:00
parent 6833fba03e
commit 71d4228601
4 changed files with 308 additions and 37 deletions
+175
View File
@@ -0,0 +1,175 @@
# 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. |
@@ -1252,11 +1252,14 @@ internal fun TopZapperBanner(
color = orange
)
// Message (if present)
// Message (if present) — image URLs collapse to "[image]"
// since the banner only has room for a single line and a
// dumped URL crowds out the sats amount. Full image renders
// inline in the engagement drawer below.
if (message.isNotBlank()) {
Spacer(Modifier.width(6.dp))
Text(
text = message,
text = com.wisp.app.ui.util.ZapMessageImage.previewText(message),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -35,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -133,8 +135,24 @@ fun ZapRow(
onProfileClick: (String) -> Unit,
modifier: Modifier = Modifier,
isPrivate: Boolean = false,
onLongPress: (() -> Unit)? = null
onLongPress: (() -> Unit)? = null,
// Passed to `RichContent` so inline links, hashtags, note/profile
// references inside a zap message stay interactive. All optional —
// when null, RichContent renders them as plain text.
eventRepo: EventRepository? = null,
onHashtagClick: ((String) -> Unit)? = null,
onNoteClick: ((String) -> Unit)? = null
) {
// Renders the zap row as a mini-post so "zapvertising" payloads —
// body text + links + inline images — surface intact in the
// engagement drawer. Single-line truncation was too lossy: a long
// promotional message + image got chopped, and only the image
// attachment was readable. Using `RichContent` here gives the
// message text + media the same treatment as a regular post body.
val displayName = remember(profile, pubkey) {
profile?.displayString
?: pubkey.toNpub().let { "${it.take(12)}...${it.takeLast(4)}" }
}
Row(
modifier = modifier
.fillMaxWidth()
@@ -148,46 +166,64 @@ fun ZapRow(
Modifier.clickable { onProfileClick(pubkey) }
}
)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
.padding(vertical = 6.dp),
verticalAlignment = Alignment.Top
) {
ProfilePicture(
url = profile?.picture,
size = 30
)
Spacer(Modifier.width(8.dp))
Text(
text = message.ifBlank { profile?.displayString ?: pubkey.toNpub().let { "${it.take(12)}...${it.takeLast(4)}" } },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
if (isPrivate) {
Icon(
painter = androidx.compose.ui.res.painterResource(com.wisp.app.R.drawable.ic_private_zap),
contentDescription = "Private zap",
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF8C00)
)
Spacer(Modifier.width(2.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
if (isPrivate) {
Icon(
painter = androidx.compose.ui.res.painterResource(com.wisp.app.R.drawable.ic_private_zap),
contentDescription = "Private zap",
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF8C00)
)
Spacer(Modifier.width(2.dp))
}
Icon(
painter = androidx.compose.ui.res.painterResource(
if (com.wisp.app.ui.util.isFiatMode()) R.drawable.ic_coin_stack else R.drawable.ic_bolt
),
contentDescription = null,
tint = Color(0xFFFF8C00),
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(2.dp))
Text(
text = AmountFormatter.formatFull(sats, LocalContext.current),
style = MaterialTheme.typography.labelLarge,
color = Color(0xFFFF8C00),
maxLines = 1
)
}
if (message.isNotBlank()) {
Spacer(Modifier.height(4.dp))
RichContent(
content = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
eventRepo = eventRepo,
onProfileClick = onProfileClick,
onNoteClick = onNoteClick,
onHashtagClick = onHashtagClick
)
}
}
Icon(
painter = androidx.compose.ui.res.painterResource(
if (com.wisp.app.ui.util.isFiatMode()) R.drawable.ic_coin_stack else R.drawable.ic_bolt
),
contentDescription = null,
tint = Color(0xFFFF8C00),
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(2.dp))
Text(
text = AmountFormatter.formatFull(sats, LocalContext.current),
style = MaterialTheme.typography.labelLarge,
color = Color(0xFFFF8C00)
)
}
}
@@ -226,7 +262,8 @@ fun ReactionDetailsSection(
isPrivate = zap.isPrivate,
onLongPress = if (zap.receiptEventId != null) {
{ inspectedZap = zap }
} else null
} else null,
eventRepo = eventRepo
)
}
}
@@ -0,0 +1,56 @@
package com.wisp.app.ui.util
/**
* Helpers for surfacing image attachments inside zap-receipt messages.
*
* Zap receipts (kind-9735) carry a free-text message in the request's
* description field. People often paste an image URL there so the zap
* comes with an inline image (a tip + a meme). The post-card top-zap
* banner has a single line of room so it shows `[image]` in place of the
* URL, while the engagement drawer renders the image inline below the
* row.
*
* iOS counterpart: see `wisp-ios/Sources/.../ZapMessage.swift`.
*/
object ZapMessageImage {
/**
* Common image file extensions we treat as inline-renderable. Matches
* the set used elsewhere in the app (RichContent's `imageExtensions`)
* so behavior is consistent.
*/
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "bmp", "svg")
/**
* Bare URL matcher — http/https only, captures the URL up to the
* first whitespace. Path-segment extension is checked separately.
*/
private val urlRegex = Regex("""https?://\S+""", RegexOption.IGNORE_CASE)
/**
* Returns the first URL inside [message] that looks like an inline
* image — i.e. the URL's path ends with a recognised image
* extension (after stripping query/fragment). Returns null if none.
*/
fun firstImageUrl(message: String): String? {
if (message.isBlank()) return null
for (match in urlRegex.findAll(message)) {
val url = match.value.trimEnd('.', ',', ')', ']', '!', '?')
val path = url.substringAfter("://").substringBefore('?').substringBefore('#')
val ext = path.substringAfterLast('.', "").lowercase()
if (ext in imageExtensions) return url
}
return null
}
/**
* Replace the image URL inside [message] with the literal `[image]`
* token so a single-line preview (e.g. the top-zap banner) reads
* something like "nice post [image]" instead of dumping a long URL.
* Falls through unchanged when no image URL is present.
*/
fun previewText(message: String): String {
val url = firstImageUrl(message) ?: return message
return message.replace(url, "[image]").trim()
}
}