android: link preview cards show the page's image, as on the web
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
Android / Core and FFI clippy and tests (push) Successful in 28s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 6m10s
Android / Build the server image (push) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:47:26 -04:00
co-authored by Claude Opus 5.5
parent 10412a2fb7
commit a213e2e186
3 changed files with 78 additions and 9 deletions
+4
View File
@@ -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)
}
@@ -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(),
+7
View File
@@ -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" }