Merge pull request #559 from dmnyc/feat/zap-with-image
feat(zap): render zap messages as mini-posts in the engagement drawer
This commit is contained in:
@@ -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. |
|
||||
@@ -1260,11 +1260,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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user