feat: render inline image links as the notification big picture

When a mention/article body contains an inline `http(s)` image link, show the
image as the notification's big picture (BigPictureStyle) instead of leaving the
raw URL in the text. NotificationContent.renderNoteText() now pulls the first
image URL out of the content — scanning all lines, since images usually sit on
their own line — using the cheap RichTextParser.isImageUrl extension check, and
strips that link from the excerpt. Videos are left in the text (Coil can't load
them as a still). The Mention and Article renderers pass the extracted URL as
bigPictureUrl; Reply stays MessagingStyle (a big picture doesn't fit a chat
bubble) and keeps its text as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
This commit is contained in:
Claude
2026-07-24 02:59:19 +00:00
parent c5b8062a0c
commit 6df7398d02
4 changed files with 109 additions and 9 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -114,6 +115,66 @@ object NotificationContent {
return ResolvedText(rewritten.take(max), cited)
}
/**
* The result of [renderNoteText]: [ResolvedText] plus [imageUrl], the first
* inline image link found anywhere in the note. When present, a renderer shows
* it as the notification's big picture and the link is stripped from [text] —
* so a mention whose body is "look at this https://…/cat.jpg" shows the photo
* instead of the raw URL.
*/
data class RenderedText(
val text: String,
val citedUsers: List<User>,
val imageUrl: String?,
)
private val whitespace = Regex("\\s+")
private val trailingPunctuation = charArrayOf('.', ',', ')', '(', '!', '?', ';', ':', '"', '\'')
/**
* The first `http(s)` image URL in [content] (anywhere, not only the first
* line — images are usually on their own line), or null. Trailing sentence
* punctuation is trimmed so "…/cat.jpg." still resolves. Videos are ignored:
* Coil can't load them as a still, so their link stays in the text.
*/
fun firstImageUrl(content: String?): String? {
if (content == null) return null
content.split(whitespace).forEach { raw ->
if (!raw.startsWith("http://", ignoreCase = true) && !raw.startsWith("https://", ignoreCase = true)) {
return@forEach
}
val url = raw.trimEnd(*trailingPunctuation)
if (RichTextParser.isImageUrl(url)) return url
}
return null
}
/**
* [resolveMentions] plus inline-image extraction: resolves `@npub` mentions,
* pulls the first image link out as [RenderedText.imageUrl], and removes that
* link from the excerpt text (collapsing the whitespace it leaves behind).
*/
fun renderNoteText(
content: String?,
max: Int = 280,
): RenderedText {
val resolved = resolveMentions(content, max = Int.MAX_VALUE)
val imageUrl = firstImageUrl(content)
val text =
if (imageUrl != null) {
resolved.text
.replace(imageUrl, "")
.replace(whitespace, " ")
.trim()
.take(max)
} else {
resolved.text.take(max)
}
return RenderedText(text, resolved.citedUsers, imageUrl)
}
suspend fun decryptZapContentAuthor(
event: LnZapRequestEvent,
signer: NostrSigner,
@@ -61,7 +61,7 @@ object ArticleNotification {
R.string.app_notification_articles_channel_message
}
val bodySource = if (event is HighlightEvent) event.quote() else event.content
val citedUsers = NotificationContent.resolveMentions(bodySource).citedUsers
val rendered = NotificationContent.renderNoteText(bodySource)
val nm = context.notificationManager()
@@ -69,22 +69,24 @@ object ArticleNotification {
context = context,
account = account,
notificationId = event.id,
users = listOf(author) + citedUsers,
users = listOf(author) + rendered.citedUsers,
notes = listOf(note),
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null }
rendered.citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
val body = NotificationContent.renderNoteText(bodySource)
nm.postStandard(
category = NotificationCategory.ARTICLE,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageBody = NotificationContent.resolveMentions(bodySource).text,
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
bigPictureUrl = body.imageUrl,
)
}
}
@@ -59,8 +59,9 @@ object MentionNotification {
val uri = NotificationRoutes.noteUri(note, accountNpub)
// Users cited inline in the text (nostr:npub/nprofile) are observed too, so
// their names fill in as their kind:0 metadata arrives.
val citedUsers = NotificationContent.resolveMentions(event.content).citedUsers
// their names fill in as their kind:0 metadata arrives. An inline image link
// is rendered as the big picture instead of shown as a raw URL.
val rendered = NotificationContent.renderNoteText(event.content)
val nm = context.notificationManager()
@@ -68,22 +69,24 @@ object MentionNotification {
context = context,
account = account,
notificationId = event.id,
users = listOf(author) + citedUsers,
users = listOf(author) + rendered.citedUsers,
notes = listOf(note),
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null }
rendered.citedUsers.all { it.metadataOrNull()?.bestName() != null }
},
) {
val body = NotificationContent.renderNoteText(event.content)
nm.postStandard(
category = category,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageBody = NotificationContent.resolveMentions(event.content).text,
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
applicationContext = context,
bigPictureUrl = body.imageUrl,
)
}
}
@@ -103,4 +103,38 @@ class NotificationContentMentionTest {
assertEquals("just a normal note with no tags", resolved.text)
assertTrue(resolved.citedUsers.isEmpty())
}
@Test
fun `an inline image link is pulled out as the big picture and stripped from the body`() {
val rendered = NotificationContent.renderNoteText("look at this https://example.com/cat.jpg amazing")
assertEquals("https://example.com/cat.jpg", rendered.imageUrl)
assertEquals("look at this amazing", rendered.text)
}
@Test
fun `an image url on its own line becomes the big picture while the caption stays the body`() {
val rendered = NotificationContent.renderNoteText("Check out my new photo\nhttps://example.com/pic.png")
assertEquals("https://example.com/pic.png", rendered.imageUrl)
assertEquals("Check out my new photo", rendered.text)
}
@Test
fun `trailing punctuation after an image url is trimmed`() {
assertEquals("https://example.com/a.webp", NotificationContent.firstImageUrl("nice pic https://example.com/a.webp."))
}
@Test
fun `a video link is not treated as an image and stays in the text`() {
val rendered = NotificationContent.renderNoteText("watch https://example.com/clip.mp4 now")
assertEquals(null, rendered.imageUrl)
assertTrue(rendered.text.contains("https://example.com/clip.mp4"))
}
@Test
fun `a bare filename without a url scheme is not mistaken for an image`() {
assertEquals(null, NotificationContent.firstImageUrl("my file is called cat.jpg locally"))
}
}