diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt
index 72aa4b9509..1c50e33e81 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -39,6 +40,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -239,11 +241,13 @@ private fun MyLoadUrlPreviewDirect(
)
} else {
Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.aspectRatio(1f)) {
+ // A page with no OpenGraph image still has its icon: shown whole, not cropped.
+ val hasImage = state.previewInfo.imageUrlFullPath.isNotBlank()
AsyncImage(
- model = state.previewInfo.imageUrlFullPath,
+ model = if (hasImage) state.previewInfo.imageUrlFullPath else state.previewInfo.iconUrlFullPath,
contentDescription = state.previewInfo.title,
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize(),
+ contentScale = if (hasImage) ContentScale.Crop else ContentScale.Fit,
+ modifier = if (hasImage) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(20.dp),
)
Text(
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt
index 823862bf31..27571bebae 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlParser.kt
@@ -49,7 +49,7 @@ class HtmlParser {
?: bodyBytes.bomCharsetName()
?: HtmlCharsetParser.detectCharset(bodyBytes)
val content = decodeBytes(bodyBytes, name)
- MetaTagsParser.parse(content)
+ MetaTagsParser.parse(content, includeTitleAndIcons = true)
}
private fun ByteArray.bomCharsetName(): String? {
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt
index 62e46ced82..5f4bd4cd4b 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt
@@ -22,8 +22,21 @@ package com.vitorpamplona.amethyst.commons.preview
import com.vitorpamplona.amethyst.commons.util.codePointToChars
+/** The `
` element a [MetaTag] was read from. */
+enum class HeadElement {
+ /** A `` tag, the only kind [MetaTagsParser.parse] yields unless asked for more. */
+ META,
+
+ /** The document's ``, its text carried as the `content` attribute. */
+ TITLE,
+
+ /** A `` whose `rel` names an icon (`icon`, `shortcut icon`, `apple-touch-icon`, …). */
+ LINK,
+}
+
data class MetaTag(
private val attrs: Map,
+ val element: HeadElement = HeadElement.META,
) {
/**
* Returns a value of an attribute specified by its name (case insensitive), or empty string if it doesn't exist.
@@ -43,7 +56,17 @@ object MetaTagsParser {
private const val NO_QUOTE = ' '
private const val META = "meta"
+ private const val LINK = "link"
private const val HEAD = "head"
+ private const val BODY = "body"
+ private const val ICON = "icon"
+ private const val REL = "rel"
+ private const val CONTENT = "content"
+
+ // A `` is only a fallback label, so one that runs on (an unclosed tag swallowing the
+ // page, a spam keyword dump) is cut rather than carried into the preview cache whole.
+ private const val MAX_TITLE_LENGTH = 300
+ private val WHITESPACE_RUN = Regex("\\s+")
// Elements whose content is text rather than markup: script and style hold raw text, title and
// textarea hold character data. A `<` inside any of them is not a tag.
@@ -66,26 +89,99 @@ object MetaTagsParser {
/** The `` that ends the interesting part of the document. */
HEAD_END,
+ /**
+ * A `` start tag. `` is optional, so on a page that omits it this is the only
+ * sign the head is over.
+ */
+ BODY_START,
+
+ /** A closed ``: its text is [TagScanner.textStart]..<[TagScanner.textEnd]. */
+ TITLE,
+
+ /** A `` whose attribute span mentions `icon`; same span fields as [META]. */
+ LINK,
+
/** Anything else: other elements, comments, declarations, unparseable markup. */
OTHER,
}
/**
* Lazily parse a partial HTML document and extract meta tags.
+ *
+ * With [includeTitleAndIcons], the document's `` and its icon ``s are yielded
+ * too, tagged by [MetaTag.element]. They are what a page without OpenGraph still offers for a
+ * preview: browsers show exactly these two in a tab.
*/
- fun parse(input: String): Sequence =
+ fun parse(
+ input: String,
+ includeTitleAndIcons: Boolean = false,
+ ): Sequence =
sequence {
val s = TagScanner(input)
+ // Meta tags past an implicit end of head are still read, as they always were. The
+ // title and icons are not: past ``, a `` is an inline SVG's tooltip
+ // ("Close", "Menu"), not the page's name.
+ var inBody = false
while (!s.exhausted()) {
- val kind = s.nextTag()
- if (kind == TagKind.HEAD_END) break
- if (kind == TagKind.META) {
- val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
- yield(MetaTag(attrs))
+ when (s.nextTag()) {
+ TagKind.HEAD_END -> {
+ break
+ }
+
+ TagKind.BODY_START -> {
+ inBody = true
+ }
+
+ TagKind.META -> {
+ val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
+ yield(MetaTag(attrs))
+ }
+
+ TagKind.TITLE -> {
+ if (includeTitleAndIcons && !inBody) {
+ val text = titleText(input, s.textStart, s.textEnd)
+ if (text.isNotEmpty()) yield(MetaTag(mapOf(CONTENT to text), HeadElement.TITLE))
+ }
+ }
+
+ TagKind.LINK -> {
+ if (includeTitleAndIcons && !inBody) {
+ val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
+ if (isIconRel(attrs[REL])) yield(MetaTag(attrs, HeadElement.LINK))
+ }
+ }
+
+ TagKind.OTHER -> {}
}
}
}
+ /**
+ * Whether a `rel` names an icon: one of its space-separated tokens is `icon` or an
+ * `apple-touch-icon` variant. `mask-icon` is deliberately not one -- it is Safari's monochrome
+ * pinned-tab silhouette, which renders as a black blob anywhere else.
+ */
+ private fun isIconRel(rel: String?): Boolean =
+ rel != null &&
+ rel.split(' ', '\t', '\n', '\r', '\u000C').any {
+ it.equals(ICON, ignoreCase = true) || it.startsWith("apple-touch-icon", ignoreCase = true)
+ }
+
+ /** The ``'s character data, with references resolved and whitespace collapsed. */
+ private fun titleText(
+ input: String,
+ from: Int,
+ to: Int,
+ ): String {
+ val raw = input.substring(from, to)
+ val decoded = if (raw.indexOf('&') < 0) raw else raw.replace(Attrs.RE_CHAR_REF, Attrs.Companion::replaceCharRefs)
+ val title = decoded.replace(WHITESPACE_RUN, " ").trim()
+ if (title.length <= MAX_TITLE_LENGTH) return title
+ // Never end on half of a surrogate pair: a lone high surrogate renders as tofu.
+ val cut = if (title[MAX_TITLE_LENGTH - 1].isHighSurrogate()) MAX_TITLE_LENGTH - 1 else MAX_TITLE_LENGTH
+ return title.substring(0, cut)
+ }
+
private class TagScanner(
private val input: String,
) {
@@ -98,6 +194,12 @@ object MetaTagsParser {
var attrsEnd = 0
private set
+ /** Character-data span of the `` [nextTag] last reported as [TagKind.TITLE]. */
+ var textStart = 0
+ private set
+ var textEnd = 0
+ private set
+
fun exhausted(): Boolean = p >= length
/**
@@ -128,6 +230,20 @@ object MetaTagsParser {
p = if (end < 0) length else end + 1
}
+ /** True when `input[from..`, a ``) down to one comparison.
when (nameEnd - nameStart) {
- META.length -> if (nameIs(nameStart, nameEnd, META)) return TagKind.META
+ META.length -> {
+ if (nameIs(nameStart, nameEnd, META)) return TagKind.META
+ if (nameIs(nameStart, nameEnd, BODY)) return TagKind.BODY_START
+ // Most ``s are stylesheets and preloads; checking the raw span for `icon`
+ // keeps their attributes from ever being parsed into a map.
+ if (nameIs(nameStart, nameEnd, LINK) && spanContains(attrsStart, attrsEnd, ICON)) return TagKind.LINK
+ }
- STYLE.length ->
+ STYLE.length -> {
if (nameIs(nameStart, nameEnd, STYLE)) {
skipRawText(STYLE_END)
} else if (nameIs(nameStart, nameEnd, TITLE)) {
+ textStart = p
skipRawText(TITLE_END)
+ textEnd = p
+ // An unclosed title runs to the end of the input: that is not a title.
+ if (p < length) return TagKind.TITLE
}
+ }
SCRIPT.length -> if (nameIs(nameStart, nameEnd, SCRIPT)) skipRawText(SCRIPT_END)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt
index 9e4714756b..3c0dfe3667 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.preview
class OpenGraphParser {
class Result(
+ /** The OpenGraph/Twitter/meta title, or the document's `` when it declares none. */
val title: String,
val description: String,
val image: String,
@@ -35,6 +36,8 @@ class OpenGraphParser {
val videoType: String = "",
/** `og:type` — what the page says it *is*, e.g. `music.song`, `video.other`, `article`. */
val type: String = "",
+ /** The best icon `` the page declares, verbatim (may be relative). Empty when none. */
+ val icon: String = "",
)
companion object {
@@ -112,6 +115,17 @@ class OpenGraphParser {
)
private val CONTENT = "content"
+ private val HREF = "href"
+ private val REL = "rel"
+ private val SIZES = "sizes"
+ private val TYPE = "type"
+
+ // How an icon with no `sizes` is ranked. An `apple-touch-icon` is 180px by Apple's
+ // convention; a plain `icon` without sizes is usually the 16/32px tab favicon. A scalable
+ // one (SVG, or `sizes="any"`) beats every bitmap: it is sharp at any card size.
+ private const val APPLE_TOUCH_ICON_DEFAULT_SIZE = 180
+ private const val ICON_DEFAULT_SIZE = 32
+ private const val SCALABLE_ICON_SIZE = Int.MAX_VALUE
}
/** Which field of [Result] a meta tag's key fills, or null when the key is not one we read. */
@@ -156,8 +170,33 @@ class OpenGraphParser {
var video = ""
var videoType = ""
var type = ""
+ var documentTitle = ""
+ var icon = ""
+ var iconSize = -1
metaTags.forEach {
+ when (it.element) {
+ HeadElement.META -> {}
+
+ HeadElement.TITLE -> {
+ if (documentTitle.isEmpty()) documentTitle = it.attr(CONTENT)
+ return@forEach
+ }
+
+ HeadElement.LINK -> {
+ val href = it.attr(HREF)
+ if (href.isNotBlank()) {
+ val size = iconSize(it)
+ // Strictly greater, so among equals the first declared wins.
+ if (size > iconSize) {
+ icon = href
+ iconSize = size
+ }
+ }
+ return@forEach
+ }
+ }
+
// A meta tag names its key in exactly one of these three attributes, but which one
// varies by site, so each is tried in turn until one is a key we read.
val field =
@@ -179,6 +218,32 @@ class OpenGraphParser {
null -> Unit
}
}
- return Result(title, description, image, audio, audioType, video, videoType, type)
+ return Result(title.ifEmpty { documentTitle }, description, image, audio, audioType, video, videoType, type, icon)
+ }
+
+ /** The pixel size an icon `` is ranked by: its largest declared size, or a default. */
+ private fun iconSize(link: MetaTag): Int {
+ val sizes = link.attr(SIZES)
+ if (sizes.contains("any", ignoreCase = true)) return SCALABLE_ICON_SIZE
+ if (link.attr(TYPE).equals("image/svg+xml", ignoreCase = true)) return SCALABLE_ICON_SIZE
+ if (link
+ .attr(HREF)
+ .substringBefore('?')
+ .substringBefore('#')
+ .endsWith(".svg", ignoreCase = true)
+ ) {
+ return SCALABLE_ICON_SIZE
+ }
+
+ // `sizes="16x16 32x32"`: rank by the largest, which is the one a decoder will pick.
+ val declared =
+ sizes
+ .split(' ')
+ .mapNotNull { it.lowercase().substringBefore('x', "").toIntOrNull() }
+ .maxOrNull()
+ if (declared != null) return declared
+
+ val isAppleTouch = link.attr(REL).contains("apple-touch-icon", ignoreCase = true)
+ return if (isAppleTouch) APPLE_TOUCH_ICON_DEFAULT_SIZE else ICON_DEFAULT_SIZE
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
index 5cb955845d..4977a6cd67 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
@@ -43,6 +43,8 @@ class UrlInfoItem(
val videoType: String = "",
/** `og:type` — what the page says it is, e.g. `music.song`, `video.other`, `article`. */
val type: String = "",
+ /** The page's best declared icon `` (favicon / apple-touch-icon). Empty when none. */
+ val icon: String = "",
) {
/** The page's host, or null when [url] is not an absolute URL. */
val verifiedHost = absoluteUrlHost(url)
@@ -66,6 +68,24 @@ class UrlInfoItem(
// which is what it did before. Only the playable gate below treats unresolvable as refusal.
val imageUrlFullPath = resolve(image) ?: image
+ /**
+ * The page's icon, absolute: the one it declared, else `/favicon.ico` at its origin -- the
+ * same guess every browser makes, since most sites that declare nothing still serve one.
+ *
+ * A `data:` icon is used as-is when it is an image (the image loader decodes those inline),
+ * and taken at its word when it is not: `href="data:,"` is how a site tells browsers it has no
+ * favicon, so guessing `/favicon.ico` there is a request that is known to miss.
+ *
+ * Null for a non-HTML URL (an image or a video is its own preview) or an unresolvable one.
+ */
+ val iconUrlFullPath: String? =
+ when {
+ !mimeType.startsWith("text/html", ignoreCase = true) -> null
+ icon.startsWith("data:image/", ignoreCase = true) -> if (icon.length <= MAX_INLINE_ICON_LENGTH) icon else resolve(DEFAULT_FAVICON)
+ icon.startsWith("data:", ignoreCase = true) -> null
+ else -> resolve(icon) ?: resolve(DEFAULT_FAVICON)
+ }
+
/**
* The declared `og:video`, absolute, but only when the declaration holds up: a [videoType] the
* renderer can actually play (a `video/` MIME, an `audio/` one, or an HLS playlist type), or
@@ -119,13 +139,29 @@ class UrlInfoItem(
else -> audioType.ifEmpty { null }
}
+ /**
+ * Whether the page has text to show without an image: a title (OpenGraph's or the document's
+ * own ``) or a description. Such a page gets the compact icon + text card rather than
+ * a bare link.
+ */
+ val hasTextPreview: Boolean = title.isNotBlank() || description.isNotBlank()
+
/**
* Whether the fetch produced something worth rendering. An image is the usual evidence, but a
* page that declared playable media counts too — a track page that ships no cover art would
* otherwise be thrown away as Empty and fall back to a bare link, which is exactly the player
- * we went to the trouble of finding.
+ * we went to the trouble of finding. So does a page with no OpenGraph at all that still has a
+ * title or description: it renders as the compact card instead of a bare link.
*/
- fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null)
+ fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null || hasTextPreview)
fun allFetchComplete(): Boolean = title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
+
+ companion object {
+ private const val DEFAULT_FAVICON = "/favicon.ico"
+
+ // Previews are cached, so an inline icon is held for as long as its card is. Real inline
+ // favicons are a few KB; anything far past that is not worth keeping in memory.
+ private const val MAX_INLINE_ICON_LENGTH = 64 * 1024
+ }
}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt
new file mode 100644
index 0000000000..4822fceaa4
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/TitleAndIconFallbackTest.kt
@@ -0,0 +1,213 @@
+/*
+ * 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.preview
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * A page with no OpenGraph still has what a browser tab shows: its `` and its icon
+ * ``s. These are read only when asked for, and only ever as a fallback.
+ */
+class TitleAndIconFallbackTest {
+ private fun headTags(html: String) = MetaTagsParser.parse(html, includeTitleAndIcons = true).toList()
+
+ private fun extract(html: String) = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(html, includeTitleAndIcons = true))
+
+ // Trimmed from the nsite that prompted this: no og:*, no twitter:*, only a title, a meta
+ // description and an SVG favicon.
+ private val noOpenGraphPage =
+ """
+ |
+ |
+ |
+ |
+ |
+ |Private Provider — Confidential AI
+ |
+ |
+ |
+ |
+ |
Hi
+ |
+ """.trimMargin()
+
+ @Test
+ fun aPageWithoutOpenGraphStillYieldsTitleDescriptionAndIcon() {
+ val info = extract(noOpenGraphPage)
+
+ assertEquals("Private Provider — Confidential AI", info.title)
+ assertEquals("A phone-first AI workspace for Android.", info.description)
+ assertEquals("favicon.svg", info.icon)
+ assertEquals("", info.image)
+ }
+
+ @Test
+ fun theDefaultParseStillYieldsOnlyMetaTags() {
+ val tags = MetaTagsParser.parse(noOpenGraphPage).toList()
+
+ assertEquals(3, tags.size)
+ assertTrue(tags.all { it.element == HeadElement.META })
+ }
+
+ @Test
+ fun onlyIconLinksAreYielded() {
+ val tags = headTags(noOpenGraphPage)
+
+ val links = tags.filter { it.element == HeadElement.LINK }
+ assertEquals(1, links.size)
+ assertEquals("favicon.svg", links[0].attr("href"))
+ }
+
+ @Test
+ fun openGraphTitleWinsOverTheDocumentTitle() {
+ val info =
+ extract(
+ """
+ |
+ | Document Title
+ |
+ |
+ """.trimMargin(),
+ )
+
+ assertEquals("OG Title", info.title)
+ }
+
+ @Test
+ fun titleWhitespaceIsCollapsed() {
+ val info = extract("\n Spread\n\t Out ")
+
+ assertEquals("Spread Out", info.title)
+ }
+
+ @Test
+ fun anUnclosedTitleIsNotATitle() {
+ val info = extract("runs to the end of the truncated body")
+
+ assertEquals("", info.title)
+ }
+
+ @Test
+ fun aTitleOutsideTheHeadIsIgnored() {
+ // An SVG in the body carries its own ; the scan stops at before it.
+ val info = extract("")
+
+ assertEquals("", info.title)
+ }
+
+ @Test
+ fun theFirstTitleWins() {
+ val info = extract("FirstSecond")
+
+ assertEquals("First", info.title)
+ }
+
+ @Test
+ fun theLargestIconWins() {
+ val info =
+ extract(
+ """
+ |
+ |
+ |
+ |
+ |
+ |
+ """.trimMargin(),
+ )
+
+ assertEquals("/apple-touch-icon.png", info.icon)
+ }
+
+ @Test
+ fun aScalableIconBeatsBitmaps() {
+ val info =
+ extract(
+ """
+ |
+ |
+ |
+ |
+ """.trimMargin(),
+ )
+
+ assertEquals("/icon.svg?v=2", info.icon)
+ }
+
+ @Test
+ fun maskIconIsNotUsed() {
+ // Safari's monochrome pinned-tab silhouette renders as a black blob anywhere else.
+ val info =
+ extract(
+ """
+ |
+ |
+ |
+ |
+ """.trimMargin(),
+ )
+
+ assertEquals("/favicon-32.png", info.icon)
+ }
+
+ @Test
+ fun iconLinksAfterTheHeadAreIgnored() {
+ val info = extract("""""")
+
+ assertEquals("", info.icon)
+ }
+
+ @Test
+ fun aBodyTitleIsIgnoredWhenTheHeadIsNeverClosed() {
+ // `` is optional: `` closes the head implicitly. An inline SVG's
+ // ("Close", "Menu") is the classic thing to then mistake for the page title.
+ val info = extract("""""")
+
+ assertEquals("", info.title)
+ assertEquals("D", info.description)
+ }
+
+ @Test
+ fun aBodyIconLinkIsIgnoredWhenTheHeadIsNeverClosed() {
+ val info = extract("""T""")
+
+ assertEquals("T", info.title)
+ assertEquals("", info.icon)
+ }
+
+ @Test
+ fun aTitleWithAttributesIsRead() {
+ val info = extract("""Helmet""")
+
+ assertEquals("Helmet", info.title)
+ }
+
+ @Test
+ fun aLongTitleIsNotCutInsideASurrogatePair() {
+ // 299 ASCII chars then an emoji: a plain take(300) keeps only its high surrogate.
+ val info = extract("" + "a".repeat(299) + "\uD83D\uDE00 tail")
+
+ assertTrue(info.title.isNotEmpty())
+ assertTrue(!info.title.last().isHighSurrogate(), "title ends in a dangling high surrogate")
+ }
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt
index 78a918541f..3410eec81c 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt
@@ -77,6 +77,7 @@ class UrlPreview {
data.video,
data.videoType,
data.type,
+ data.icon,
)
}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt
index bda5b0c2a9..b27eb59efe 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolution.jvmAndroid.kt
@@ -29,7 +29,7 @@ actual fun resolveHttpUrl(
reference: String,
): String? =
runCatching {
- val baseUri = base?.let { runCatching { URI(it).toURL().toURI() }.getOrNull() }
+ val baseUri = base?.let { runCatching { URI(it).toURL().toURI().withRootPath() }.getOrNull() }
val resolved = if (baseUri != null) baseUri.resolve(reference) else URI(reference)
if (!resolved.scheme.equals("http", ignoreCase = true) &&
!resolved.scheme.equals("https", ignoreCase = true)
@@ -39,3 +39,23 @@ actual fun resolveHttpUrl(
resolved.toURL().toString()
}
}.getOrNull()
+
+/**
+ * `https://host` as `https://host/`. RFC 3986 resolves a relative reference against an empty base path
+ * as if the path were `/`, and the JDK does, but Android's `java.net.URI` does not: it glued
+ * `y18.svg` onto `https://news.ycombinator.com` as `https://news.ycombinator.comy18.svg`, so the
+ * page's icon (or a relative og:image) pointed at a host that does not exist.
+ */
+private fun URI.withRootPath(): URI =
+ if (isOpaque || !rawPath.isNullOrEmpty() || rawAuthority == null) {
+ this
+ } else {
+ // From the raw (still-encoded) parts: the multi-argument constructor would encode them again.
+ URI(
+ buildString {
+ append(scheme).append("://").append(rawAuthority).append('/')
+ rawQuery?.let { append('?').append(it) }
+ rawFragment?.let { append('#').append(it) }
+ },
+ )
+ }
diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt
new file mode 100644
index 0000000000..5998927fbe
--- /dev/null
+++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItemTextPreviewTest.kt
@@ -0,0 +1,109 @@
+/*
+ * 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.preview
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * A page with no OpenGraph image is still worth a card when it has text: the compact icon + text
+ * card, built from its title, description and favicon.
+ */
+class UrlInfoItemTextPreviewTest {
+ private fun page(
+ url: String = "https://example.nsite.lol/",
+ title: String = "",
+ description: String = "",
+ image: String = "",
+ icon: String = "",
+ mimeType: String = "text/html; charset=utf-8",
+ ) = UrlInfoItem(url = url, title = title, description = description, image = image, mimeType = mimeType, icon = icon)
+
+ @Test
+ fun aTitleAloneIsEnoughToKeepThePreview() {
+ assertTrue(page(title = "Private Provider").fetchComplete())
+ }
+
+ @Test
+ fun aDescriptionAloneIsEnoughToKeepThePreview() {
+ assertTrue(page(description = "A workspace").fetchComplete())
+ }
+
+ @Test
+ fun aPageWithNothingStaysEmpty() {
+ assertFalse(page().fetchComplete())
+ assertFalse(page(title = " ").fetchComplete())
+ }
+
+ @Test
+ fun aRelativeIconIsResolvedAgainstThePage() {
+ assertEquals(
+ "https://example.nsite.lol/docs/favicon.svg",
+ page(url = "https://example.nsite.lol/docs/index.html", icon = "favicon.svg").iconUrlFullPath,
+ )
+ }
+
+ @Test
+ fun noDeclaredIconFallsBackToTheOriginFavicon() {
+ assertEquals(
+ "https://example.nsite.lol/favicon.ico",
+ page(url = "https://example.nsite.lol/a/b/c.html").iconUrlFullPath,
+ )
+ }
+
+ @Test
+ fun aNonHttpIconFallsBackToTheOriginFavicon() {
+ assertEquals(
+ "https://example.nsite.lol/favicon.ico",
+ page(icon = "file:///etc/passwd").iconUrlFullPath,
+ )
+ }
+
+ @Test
+ fun nonHtmlUrlsHaveNoIcon() {
+ assertNull(page(url = "https://example.com/a.png", image = "https://example.com/a.png", mimeType = "image/png").iconUrlFullPath)
+ }
+
+ @Test
+ fun anUppercaseHtmlMimeStillGetsAnIcon() {
+ // UrlPreview stores MediaType.toString(), which keeps the server's spelling.
+ assertEquals(
+ "https://example.nsite.lol/favicon.ico",
+ page(mimeType = "Text/HTML; charset=UTF-8").iconUrlFullPath,
+ )
+ }
+
+ @Test
+ fun anEmptyDataIconMeansTheSiteHasNoIcon() {
+ // `` is the common way to tell browsers not to request
+ // /favicon.ico. Requesting it anyway is a guaranteed miss.
+ assertNull(page(icon = "data:,").iconUrlFullPath)
+ }
+
+ @Test
+ fun anInlineImageIconIsUsedAsIs() {
+ val inline = "data:image/png;base64,iVBORw0KGgo="
+ assertEquals(inline, page(icon = inline).iconUrlFullPath)
+ }
+}
diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt
index 228498fdf1..2a2d0f70db 100644
--- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt
+++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/MetaTagsParserBenchmark.kt
@@ -160,6 +160,19 @@ class MetaTagsParserBenchmark {
}
bench("no , whole doc", open, 2_000) { MetaTagsParser.parse(it).count() }
+ // What production runs (HtmlParser asks for the and icon s too). Should sit
+ // on top of the rows above: the 60 preload links never reach the attribute parser.
+ bench("spa head, +title/icons", spa, 50_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
+ bench("spa head, og: + fallbacks", spa, 50_000) {
+ OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(it, includeTitleAndIcons = true)).title.length
+ }
+ bench("heavy head, +title/icons", heavy, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
+ bench("no , +title/icons", open, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
+
+ val spaFallbacks = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(spa, includeTitleAndIcons = true))
+ assertEquals("Example — Your Network. Your Rules.", spaFallbacks.title)
+ assertEquals("/favicon.svg", spaFallbacks.icon)
+
assertTrue(MetaTagsParser.parse(open).count() >= 64)
}
}
diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt
new file mode 100644
index 0000000000..353aacd5d1
--- /dev/null
+++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/HttpUrlResolutionTest.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/**
+ * A relative reference against a base with no path resolves as if the path were `/` (RFC 3986).
+ * Android's `java.net.URI` got this wrong — `y18.svg` against `https://news.ycombinator.com` became
+ * `https://news.ycombinator.comy18.svg` — so the base is normalized before resolving.
+ */
+class HttpUrlResolutionTest {
+ @Test
+ fun relativeReferenceAgainstAPathlessBase() {
+ assertEquals("https://news.ycombinator.com/y18.svg", resolveHttpUrl("https://news.ycombinator.com", "y18.svg"))
+ assertEquals("https://news.ycombinator.com/favicon.ico", resolveHttpUrl("https://news.ycombinator.com", "/favicon.ico"))
+ }
+
+ @Test
+ fun pathlessBaseKeepsItsQueryAndEncoding() {
+ assertEquals("https://a.example/icon.png", resolveHttpUrl("https://a.example?q=a%20b", "icon.png"))
+ // Encoded characters are carried over as they are, not encoded a second time.
+ assertEquals("https://a.example/icons/a%20b.png", resolveHttpUrl("https://a.example?q=a%20b", "icons/a%20b.png"))
+ }
+
+ @Test
+ fun basesWithAPathAreUnchanged() {
+ assertEquals("https://a.example/dir/icon.png", resolveHttpUrl("https://a.example/dir/page.html", "icon.png"))
+ assertEquals("https://cdn.example/x.png", resolveHttpUrl("https://a.example/dir/", "//cdn.example/x.png"))
+ }
+
+ @Test
+ fun nonHttpStaysRefused() {
+ assertNull(resolveHttpUrl("https://a.example", "file:///etc/passwd"))
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt
index a084cb0edf..66853c6bac 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UrlPreviewCard.kt
@@ -20,26 +20,37 @@
*/
package com.vitorpamplona.amethyst.commons.ui.components
+import androidx.collection.LruCache
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
+import coil3.network.HttpException
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
@@ -50,9 +61,12 @@ import com.vitorpamplona.amethyst.commons.resources.link_actions_dialog_title
import com.vitorpamplona.amethyst.commons.resources.url_preview_open_in_browser
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.stringRes
+import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier
+import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier
+import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.commons.ui.theme.previewCardImageModifier
import kotlinx.coroutines.launch
@@ -101,22 +115,50 @@ fun UrlPreviewCard(
}
}
- Column(
- modifier =
- MaterialTheme.colorScheme.innerPostModifier
- .combinedClickable(
- onClick = {
- if (onCardClick != null) {
- onCardClick()
- } else {
- runCatching { uri.openUri(url) }
- }
- },
- onLongClick = {
- popupExpanded.value = true
- },
- ),
- ) {
+ val cardModifier =
+ MaterialTheme.colorScheme.innerPostModifier
+ .combinedClickable(
+ onClick = {
+ if (onCardClick != null) {
+ onCardClick()
+ } else {
+ runCatching { uri.openUri(url) }
+ }
+ },
+ onLongClick = {
+ popupExpanded.value = true
+ },
+ )
+
+ // Only meaningful when the card's own tap does something else (e.g. opening the comment
+ // thread); otherwise it would duplicate the card's open-in-browser tap.
+ val hasCardClick = onCardClick != null
+ val onOpenInBrowser: (() -> Unit)? =
+ remember(hasCardClick, url, uri) {
+ if (hasCardClick) {
+ { runCatching { uri.openUri(url) } }
+ } else {
+ null
+ }
+ }
+
+ // A page with no picture to lead with -- most often one with no OpenGraph at all, previewed
+ // from its ``, meta description and favicon -- gets the short, wide card. Painting the
+ // big layout without its image would leave a text block that only looks broken.
+ if (previewInfo.imageUrlFullPath.isBlank() && previewInfo.hasTextPreview) {
+ CompactUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser)
+ } else {
+ LargeUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser)
+ }
+}
+
+@Composable
+private fun LargeUrlPreviewCard(
+ previewInfo: UrlInfoItem,
+ modifier: Modifier,
+ onOpenInBrowser: (() -> Unit)?,
+) {
+ Column(modifier = modifier) {
// A Loaded preview no longer implies an image: a player page that ships no cover art is
// kept (it has media to play), and painting its empty string left a blank 180dp box.
if (previewInfo.imageUrlFullPath.isNotBlank()) {
@@ -141,20 +183,7 @@ fun UrlPreviewCard(
overflow = TextOverflow.Ellipsis,
)
- // Only meaningful when the card's own tap does something else (e.g. opening the
- // comment thread); otherwise it would duplicate the card's open-in-browser tap.
- if (onCardClick != null) {
- IconButton(
- onClick = { runCatching { uri.openUri(url) } },
- ) {
- Icon(
- symbol = MaterialSymbols.AutoMirrored.OpenInNew,
- contentDescription = stringRes(Res.string.url_preview_open_in_browser),
- modifier = Size14Modifier,
- tint = Color.Gray,
- )
- }
- }
+ onOpenInBrowser?.let { OpenInBrowserButton(it) }
}
Text(
@@ -177,3 +206,121 @@ fun UrlPreviewCard(
Spacer(modifier = DoubleVertSpacer)
}
}
+
+/**
+ * The short, wide card: the site's icon on the left, its title, description and host on the
+ * right. Built from what every page has even without OpenGraph -- what a browser tab shows.
+ */
+@Composable
+private fun CompactUrlPreviewCard(
+ previewInfo: UrlInfoItem,
+ modifier: Modifier,
+ onOpenInBrowser: (() -> Unit)?,
+) {
+ val host = previewInfo.verifiedHost ?: previewInfo.url
+
+ Row(
+ modifier = modifier.padding(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ UrlPreviewIcon(previewInfo.iconUrlFullPath, host)
+
+ Spacer(modifier = DoubleHorzSpacer)
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = previewInfo.title.ifBlank { host },
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ if (previewInfo.description.isNotBlank()) {
+ Text(
+ text = previewInfo.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ // When there is no title the host already stands in for it on the first line.
+ if (previewInfo.title.isNotBlank()) {
+ Text(
+ text = host,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ onOpenInBrowser?.let { OpenInBrowserButton(it) }
+ }
+}
+
+/**
+ * Icon URLs the server refused (404 and the like) in this process. The image loader caches
+ * successes but not failures, so without this a site with no `/favicon.ico` would be asked for it
+ * again -- and 404 again -- every time its card scrolled back into view.
+ */
+private val failedIconUrls = LruCache(200)
+
+/**
+ * The site's icon in a fixed square. A generic globe sits there until the icon actually loads,
+ * so a site whose favicon is missing (the `/favicon.ico` guess is only a guess) or undecodable
+ * still gets a tidy card instead of an empty hole.
+ */
+@Composable
+private fun UrlPreviewIcon(
+ iconUrl: String?,
+ contentDescription: String,
+) {
+ Box(
+ modifier =
+ UrlPreviewIconModifier
+ .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ var loaded by remember(iconUrl) { mutableStateOf(false) }
+
+ if (!loaded) {
+ Icon(
+ symbol = MaterialSymbols.Language,
+ contentDescription = null,
+ modifier = Size24Modifier,
+ tint = Color.Gray,
+ )
+ }
+
+ if (iconUrl != null && failedIconUrls[iconUrl] == null) {
+ AsyncImage(
+ model = iconUrl,
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Fit,
+ modifier = UrlPreviewIconImageModifier,
+ onSuccess = { loaded = true },
+ // Only a server's answer is remembered. A network error (offline, a timeout) says
+ // nothing about the icon and must be retried once the connection is back.
+ onError = { if (it.result.throwable is HttpException) failedIconUrls.put(iconUrl, Unit) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun OpenInBrowserButton(onClick: () -> Unit) {
+ IconButton(onClick = onClick) {
+ Icon(
+ symbol = MaterialSymbols.AutoMirrored.OpenInNew,
+ contentDescription = stringRes(Res.string.url_preview_open_in_browser),
+ modifier = Size14Modifier,
+ tint = Color.Gray,
+ )
+ }
+}
+
+private val UrlPreviewIconModifier = Modifier.size(48.dp).clip(SmallBorder)
+private val UrlPreviewIconImageModifier = Modifier.fillMaxSize().padding(6.dp)