|
|
|
@@ -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(),
|
|
|
|
|