feat: preview PDF links inline with first-page thumbnail and full pager

Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs
(detected by .pdf extension, NIP-92 imeta m tag, or application/pdf
Content-Type) render a card showing the first page, filename, and page
count. Tapping opens a full-screen HorizontalPager over every page
rendered on demand with PdfRenderer. Long-press surfaces the existing
share menu. Uses only the built-in Android PdfRenderer; no new
dependencies. Desktop continues to fall back to a clickable link.
This commit is contained in:
Claude
2026-04-18 23:06:49 +00:00
parent 8c71d490a7
commit ecdbc80fc1
11 changed files with 772 additions and 11 deletions
@@ -68,6 +68,17 @@ class EncryptedMediaUrlImage(
val encryptionNonce: ByteArray,
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
@Immutable
open class MediaUrlPdf(
url: String,
description: String? = null,
hash: String? = null,
blurhash: String? = null,
dim: DimensionTag? = null,
uri: String? = null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
@Immutable
open class MediaUrlVideo(
url: String,
@@ -58,17 +58,21 @@ class RichTextParser {
val isImage: Boolean
val isVideo: Boolean
val isPdf: Boolean
if (contentType != null) {
isImage = contentType.startsWith("image/")
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/")
isPdf = contentType.startsWith("application/pdf")
} else if (fullUrl.startsWith("data:")) {
isImage = fullUrl.startsWith("data:image/")
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
isPdf = fullUrl.startsWith("data:application/pdf")
} else {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
return if (isImage) {
@@ -93,6 +97,16 @@ class RichTextParser {
uri = callbackUri,
mimeType = contentType,
)
} else if (isPdf) {
MediaUrlPdf(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
uri = callbackUri,
mimeType = contentType,
)
} else {
null
}
@@ -158,6 +172,7 @@ class RichTextParser {
val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys
val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys
val pdfUrls = mediaForPager.filterValues { it is MediaUrlPdf }.keys
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
@@ -165,7 +180,7 @@ class RichTextParser {
val newContent = fixMissingSpaces(content, allUrls)
val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags)
val segments = findTextSegments(newContent, imageUrls, videoUrls, pdfUrls, urlSet, emojiMap, tags)
val mediaForPagerWithBase64 =
mediaForPager +
@@ -197,6 +212,7 @@ class RichTextParser {
content: String,
images: Set<String>,
videos: Set<String>,
pdfs: Set<String>,
urls: Urls,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
@@ -211,7 +227,7 @@ class RichTextParser {
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
}
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
@@ -262,6 +278,7 @@ class RichTextParser {
word: String,
images: Set<String>,
videos: Set<String>,
pdfs: Set<String>,
urls: Urls,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
@@ -288,6 +305,14 @@ class RichTextParser {
}
}
if (pdfs.contains(word)) {
return if (urls.withoutScheme.contains(word)) {
PdfSegment("https://$word")
} else {
PdfSegment(word)
}
}
if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word)
if (urls.withScheme.contains(word)) return LinkSegment(word)
@@ -377,9 +402,11 @@ class RichTextParser {
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
val pdfExt = listOf("pdf")
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() }
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Regex =
@@ -421,6 +448,11 @@ class RichTextParser {
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isPdfUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isValidURL(url: String?): Boolean =
try {
if (url != null) {
@@ -496,4 +528,6 @@ val mimeTypeMap: Map<String, String> =
"m4a" to "audio/mp4",
"aac" to "audio/aac",
"flac" to "audio/flac",
// Documents
"pdf" to "application/pdf",
)
@@ -62,6 +62,11 @@ class VideoSegment(
segment: String,
) : Segment(segment)
@Immutable
class PdfSegment(
segment: String,
) : Segment(segment)
@Immutable
class LinkSegment(
segment: String,
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PdfParserTest {
@Test
fun detectsPdfByExtension() {
val url = "https://example.com/docs/paper.pdf"
val state = RichTextParser().parseText(url, EmptyTagList, null)
val pdfMedia = state.mediaForPager[url]
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf for .pdf URL")
val segment = state.paragraphs[0].words[0]
assertTrue(segment is PdfSegment, "Expected PdfSegment for .pdf URL, got ${segment::class.simpleName}")
assertEquals(url, segment.segmentText)
}
@Test
fun detectsPdfFromImetaMimeTypeWithoutExtension() {
val url = "https://files.example.com/abcd1234"
val tags =
ImmutableListOfLists(
arrayOf(
arrayOf("imeta", "url $url", "m application/pdf"),
),
)
val state = RichTextParser().parseText(url, tags, null)
val pdfMedia = state.mediaForPager[url]
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf from imeta MIME tag")
assertEquals("application/pdf", (pdfMedia as MediaUrlPdf).mimeType)
val segment = state.paragraphs[0].words[0]
assertTrue(segment is PdfSegment, "Expected PdfSegment from imeta MIME tag, got ${segment::class.simpleName}")
}
@Test
fun isPdfUrlHelperMatchesPdfExtension() {
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf"))
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF"))
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc"))
}
}