From 6df7398d02b0b89162ff7367c50ec1deae86ee60 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 02:59:19 +0000 Subject: [PATCH] feat: render inline image links as the notification big picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r --- .../notifications/NotificationContent.kt | 61 +++++++++++++++++++ .../renderers/ArticleNotification.kt | 10 +-- .../renderers/MentionNotification.kt | 13 ++-- .../NotificationContentMentionTest.kt | 34 +++++++++++ 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt index fc1d5b103f..131bf60f0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt @@ -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, + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt index c50344a394..9e886a3e30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt index e1a0380350..a996247dc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt @@ -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, ) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt index 1fd00192d2..7b2783a490 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/notifications/NotificationContentMentionTest.kt @@ -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")) + } }