diff --git a/ZAP_IMAGE_PARITY.md b/ZAP_IMAGE_PARITY.md new file mode 100644 index 0000000..0f8ec3f --- /dev/null +++ b/ZAP_IMAGE_PARITY.md @@ -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. | diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt index abc708b..b01a707 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt @@ -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, diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ReactionDetailsSection.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ReactionDetailsSection.kt index e26896f..2b4dcb4 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ReactionDetailsSection.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ReactionDetailsSection.kt @@ -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 ) } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/util/ZapMessageImage.kt b/app/src/main/kotlin/com/wisp/app/ui/util/ZapMessageImage.kt new file mode 100644 index 0000000..4112600 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/ui/util/ZapMessageImage.kt @@ -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() + } +}