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:
+11
@@ -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,
|
||||
|
||||
+36
-2
@@ -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",
|
||||
)
|
||||
|
||||
+5
@@ -62,6 +62,11 @@ class VideoSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PdfSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(
|
||||
segment: String,
|
||||
|
||||
+69
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user