android: a link in a note renders as a link card, not a bare URL
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m33s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m33s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user