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,