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
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:
@@ -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(),
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user