AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
3 changed files with 78 additions and 9 deletions
Showing only changes of commit a213e2e186 - Show all commits
+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" }