Compare commits
3
Commits
729d0dadf1
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2854a029 | ||
|
|
62338bb0a4 | ||
|
|
1a41373347 |
@@ -472,9 +472,13 @@ class BoardViewModel(
|
||||
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||
* lie than showing it one frame stale.
|
||||
*
|
||||
* Search results are left alone — they are the answer to a query, not a live
|
||||
* view, and re-running the board query underneath them would replace the hits
|
||||
* with the whole board.
|
||||
* While a search is running the QUERY is re-run rather than the board's
|
||||
* destination — running `load` here would replace the hits with the whole
|
||||
* board, which is why this branch exists at all. It used to keep the existing
|
||||
* list instead, and that was right for a note whose place in the pile changed
|
||||
* and wrong for one that left it: trashing a hit left the card sitting there,
|
||||
* with a snackbar saying it was gone, until the query happened to re-run
|
||||
* (#3111). Re-asking is still the answer to the query, just a current one.
|
||||
*/
|
||||
private fun mutate(
|
||||
closeEditor: Boolean = false,
|
||||
@@ -486,10 +490,8 @@ class BoardViewModel(
|
||||
try {
|
||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||
val notes =
|
||||
if (state.searching) {
|
||||
state.notes
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { load(state.destination) }
|
||||
withContext(Dispatchers.IO) {
|
||||
if (state.searching) core.searchNotes(state.query) else load(state.destination)
|
||||
}
|
||||
// On IO, not here: re-deriving the alarm reads every note
|
||||
// that carries a reminder, and this line runs on the main
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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,
|
||||
) {
|
||||
// Every element of the Modifier chain stays on ONE line, which is why the shape
|
||||
// and the two paddings are named first. `standard:chain-method-continuation`
|
||||
// wants a `.` that follows a MULTILINE element glued to its closing paren —
|
||||
// `).padding(…)` — which is unreadable, so the multiline element is avoided
|
||||
// instead (Scribe #3110).
|
||||
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
||||
val padH = if (compact) 8.dp else 10.dp
|
||||
val padV = if (compact) 6.dp else 8.dp
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
.padding(horizontal = padH, vertical = padV),
|
||||
) {
|
||||
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