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" }