From a213e2e1868f91048105dcce8c2d1dd0a1256a90 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 07:47:26 -0400 Subject: [PATCH] android: link preview cards show the page's image, as on the web The card draws the linked page's og:image in a strip down its left edge, cropped to the card's height: 96dp full, 48dp compact, matching the web's w-24 and w-12. The image is remote, so the phone fetches it from whatever host the link points at, exactly as a browser does for the web card. The operator chose that parity (Scribe #3307). The image is Coil 3's AsyncImage, with OkHttp as its fetcher. Coil's disk cache means a card scrolled past twice costs one download. When there is no image, or it fails to load, nothing is drawn rather than an empty box, and the card is the text card it was before. Co-Authored-By: Claude Opus 5.5 --- android/app/build.gradle.kts | 4 + .../fabledsword/inkwell/ui/LinkPreviewRow.kt | 76 ++++++++++++++++--- android/gradle/libs.versions.toml | 7 ++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6f459af..f7cb6a8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -291,5 +291,9 @@ dependencies { implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) + // A link preview's image (LinkPreviewRow.kt). + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + testImplementation(libs.junit) } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/LinkPreviewRow.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/LinkPreviewRow.kt index 4b8cd02..2ab833d 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/LinkPreviewRow.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/LinkPreviewRow.kt @@ -2,21 +2,36 @@ package com.fabledsword.inkwell.ui import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape 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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +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.fabledsword.inkwell.core.LinkPreview import com.fabledsword.inkwell.core.Note private val PREVIEW_RADIUS = 8.dp +// The image's width on each size of card, matching the web's `w-24` and `w-12`. +private val FULL_IMAGE_WIDTH = 96.dp +private val COMPACT_IMAGE_WIDTH = 48.dp + /** * A URL that is the WHOLE body, whitespace either side allowed. * @@ -57,12 +72,12 @@ fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body) * footnote to it. Full size is for a note that IS a URL, where the link is the * note and a compact strip would be a card with nothing on it. * - * No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE - * third-party address, so drawing it would have this app fetch from whatever host - * a link happens to point at — on a phone, on possibly metered data, and as the - * first image loading anywhere in this client. That is a decision about privacy - * and data use rather than a rendering detail, so the text card ships and the - * image is left to be asked for (Scribe #3307). + * The image sits in a strip down the left edge, cropped to the card's height, as in + * the web's `LinkPreview.vue`. `image_url` is a REMOTE third-party address — the + * linked page's og:image — so the phone fetches it from whatever host the link + * points at, exactly as a browser does for the web card. The operator chose that + * parity knowingly (Scribe #3307). Coil's disk cache means a card scrolled past + * twice costs one download, not two. */ @Composable fun LinkPreviewCard( @@ -79,14 +94,57 @@ fun LinkPreviewCard( val padH = if (compact) 8.dp else 10.dp val padV = if (compact) 6.dp else 8.dp - Column( + // IntrinsicSize.Min so the image strip can fill exactly the text's height, which + // is what the web's `self-stretch` does. + Row( modifier = modifier .fillMaxWidth() + .height(IntrinsicSize.Min) .clip(shape) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) - .padding(horizontal = padH, vertical = padV), + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape), ) { + PreviewImage(url = preview.imageUrl, compact = compact) + PreviewText( + preview = preview, + compact = compact, + modifier = Modifier.weight(1f).padding(horizontal = padH, vertical = padV), + ) + } +} + +/** + * The preview's image, or nothing at all. + * + * Nothing rather than an empty box when there is no image, AND when it fails to + * load: a page that names an og:image it no longer serves is common, and a grey + * square says "broken" about a card whose words are perfectly good. Same outcome + * as a preview that never had an image. + */ +@Composable +private fun PreviewImage( + url: String?, + compact: Boolean, +) { + var failed by remember(url) { mutableStateOf(false) } + if (url.isNullOrBlank() || failed) return + AsyncImage( + model = url, + // Decoration: the title beside it already says what the link is. + contentDescription = null, + contentScale = ContentScale.Crop, + onError = { failed = true }, + modifier = Modifier.width(if (compact) COMPACT_IMAGE_WIDTH else FULL_IMAGE_WIDTH).fillMaxHeight(), + ) +} + +@Composable +private fun PreviewText( + preview: LinkPreview, + compact: Boolean, + modifier: Modifier, +) { + Column(modifier = modifier) { preview.siteName?.takeIf { it.isNotBlank() }?.let { site -> Text( text = site.uppercase(), diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a82ffcd..edeb7ef 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -32,6 +32,11 @@ jna = "5.14.0" junit = "4.13.2" +# Coil draws a link preview's image (Scribe #3307), the first image this client +# loads. Coil 3 ships its networking separately; the OkHttp fetcher is the one its +# docs name as the default, and it registers itself, so no loader is configured. +coil = "3.6.3" + [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } @@ -51,6 +56,8 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "work" } junit = { module = "junit:junit", version.ref = "junit" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }