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))
|
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
|
// 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
|
// first line of the body IS the note's name, at the same weight as the rest of
|
||||||
// it (M13 steps 3 and 4).
|
// 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)
|
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
|
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||||
// it reads as a rendering bug.
|
// it reads as a rendering bug.
|
||||||
if (note.body.isBlank()) {
|
if (note.body.isBlank() && note.previews.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.board_empty_note),
|
text = stringResource(R.string.board_empty_note),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
Reference in New Issue
Block a user