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
|
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||||
* lie than showing it one frame stale.
|
* lie than showing it one frame stale.
|
||||||
*
|
*
|
||||||
* Search results are left alone — they are the answer to a query, not a live
|
* While a search is running the QUERY is re-run rather than the board's
|
||||||
* view, and re-running the board query underneath them would replace the hits
|
* destination — running `load` here would replace the hits with the whole
|
||||||
* with the whole board.
|
* 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(
|
private fun mutate(
|
||||||
closeEditor: Boolean = false,
|
closeEditor: Boolean = false,
|
||||||
@@ -486,10 +490,8 @@ class BoardViewModel(
|
|||||||
try {
|
try {
|
||||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||||
val notes =
|
val notes =
|
||||||
if (state.searching) {
|
withContext(Dispatchers.IO) {
|
||||||
state.notes
|
if (state.searching) core.searchNotes(state.query) else load(state.destination)
|
||||||
} else {
|
|
||||||
withContext(Dispatchers.IO) { load(state.destination) }
|
|
||||||
}
|
}
|
||||||
// On IO, not here: re-deriving the alarm reads every note
|
// On IO, not here: re-deriving the alarm reads every note
|
||||||
// that carries a reminder, and this line runs on the main
|
// 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))
|
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