From 62338bb0a455dc2c76a974c8455d9d2a996decec Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 1 Sep 2026 18:51:51 -0400 Subject: [PATCH] android: a link in a note renders as a link card, not a bare URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web and desktop have shown link previews since #2898; the phone showed the raw address. The data was already on the device — `Note.previews` is populated by the core and carried through the FFI — and nothing under `app/src/main` read the field. The three presentation rules are copied from `NoteCard.vue` rather than re-decided, so the same note reads the same way on every surface: * A note that is NOTHING but a URL renders as its preview and nothing else. Printing the address under a card that already says where it goes is saying the same thing twice, badly. * Links mentioned INSIDE a note get a compact strip at the FOOT of the card. Above the body would put a stranger's headline where the note's first line should be; the web learned that in M13. * Several stack. `LONE_URL` mirrors the web's `LONE_URL_RE` including the tolerated whitespace — if the two regexes disagree, one note reads as a card here and a paragraph there. Falling back to the URL is deliberate in all three of the cases that produce no preview: not a lone URL, not unfurled yet, or never unfurlable. A note written on the phone and not yet synced is permanently in the middle one, because the unfurl is server-side (`unfurl_queue.py`) and arrives on a later pull — so that state has to look deliberate, and showing the link does. No unfurl fetch was added here, and none should be: a phone fetching OG tags would be a second SSRF-hardened fetcher on the surface least able to afford the call. ## No image, and that is a question rather than an omission `LinkPreview.image_url` is a REMOTE third-party address — the web renders it straight from whatever host the link points at. Matching that here would have this app fetch images from arbitrary hosts, on a phone, on possibly metered data, and would make it the first image loading anywhere in this client: there is no loader, no cache, and not one `Image(` in the whole app today. That is a decision about privacy and data use, not a rendering detail, so the text card ships and the image is asked about rather than assumed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c --- .../thoughtsync/ui/LinkPreviewRow.kt | 116 ++++++++++++++++++ .../fabledsword/thoughtsync/ui/NoteCard.kt | 25 +++- 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/LinkPreviewRow.kt diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/LinkPreviewRow.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/LinkPreviewRow.kt new file mode 100644 index 0000000..428afa8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/LinkPreviewRow.kt @@ -0,0 +1,116 @@ +package com.fabledsword.thoughtsync.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.fabledsword.thoughtsync.core.LinkPreview +import com.fabledsword.thoughtsync.core.Note + +private val PREVIEW_RADIUS = 8.dp + +/** + * A URL that is the WHOLE body, whitespace either side allowed. + * + * Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to + * agree on what counts as "this note is a link", or the same note reads as a card + * on one and a paragraph on the other. Someone pasting a link rarely trims it, + * which is why the surrounding whitespace is tolerated rather than rejected. + */ +private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""") + +/** + * The preview for a note that is nothing but a URL, or null. + * + * Null covers three different situations that all render the same way — the body + * is not a lone URL, the server has not unfurled it yet, or it never could. The + * card falls back to showing the URL as text in every one of them, so it is never + * blank and the link is never unreachable. + * + * A note written on the phone and not yet synced is permanently in the middle + * case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later + * pull. That is the honest behaviour and it has to look deliberate, which showing + * the URL does. + */ +fun loneUrlPreview(note: Note): LinkPreview? { + if (!LONE_URL.matches(note.body)) return null + val url = note.body.trim() + return note.previews.firstOrNull { it.url == url } +} + +/** True when the body is a lone URL, whether or not a preview has arrived for it. */ +fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body) + +/** + * A fetched link preview, in one of two sizes. + * + * [compact] is a single row — one line of title and the site — for a URL mentioned + * *inside* a note that has its own words. The note is the thing; the link is a + * 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). + */ +@Composable +fun LinkPreviewCard( + preview: LinkPreview, + compact: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(PREVIEW_RADIUS)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(PREVIEW_RADIUS), + ) + .padding(horizontal = if (compact) 8.dp else 10.dp, vertical = if (compact) 6.dp else 8.dp), + ) { + preview.siteName?.takeIf { it.isNotBlank() }?.let { site -> + Text( + text = site.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + // The URL stands in for a missing title so the row always says SOMETHING + // about where it goes. + text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url, + style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // First thing to go when there is no room — the compact row is a footnote and + // a description would make it the loudest part of the card. + if (!compact) { + preview.description?.takeIf { it.isNotBlank() }?.let { body -> + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index 26ff0fa..99e1812 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -123,16 +123,37 @@ fun NoteCard( Spacer(Modifier.height(8.dp)) } + // A note that is NOTHING but a URL renders as its preview and nothing + // else — printing the raw address under a card that already says where it + // goes is saying the same thing twice, badly. Until the unfurl lands, or + // if it never does, `preview` is null and the body falls through to + // NoteBody, which shows the URL. Never a blank card. + val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) } + // Body then checklist, in order — a note can carry both (M13 step 2). The // first line of the body IS the note's name, at the same weight as the rest of // it (M13 steps 3 and 4). - if (note.body.isNotBlank()) { + if (lonePreview != null) { + LinkPreviewCard(preview = lonePreview, compact = false) + } else if (note.body.isNotBlank()) { NoteBody(note = note, onToggleItem = onToggleItem) } + // Links mentioned INSIDE a note: a compact strip at the foot of the card, + // under the note's own words rather than stacked on top of them. Putting + // them above would set a stranger's headline where the note's first line + // should be — the web learned that in M13 and moved them down. + if (!isLoneUrl(note) && note.previews.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + note.previews.forEach { preview -> + LinkPreviewCard(preview = preview, compact = true) + Spacer(Modifier.height(4.dp)) + } + } + // A note with nothing in it still has to occupy the board legibly — otherwise // it reads as a rendering bug. - if (note.body.isBlank()) { + if (note.body.isBlank() && note.previews.isEmpty()) { Text( text = stringResource(R.string.board_empty_note), style = MaterialTheme.typography.bodyMedium,