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")) + } }