AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
3 changed files with 92 additions and 80 deletions
Showing only changes of commit 802eab4ef9 - Show all commits
@@ -195,10 +195,7 @@ fun BoardScreen(
onMenu = { scope.launch { drawerState.open() } },
)
// The main board only, and not while searching, as on the web: a
// search spans everything, so a filter row over its hits would be a
// control that does nothing.
if (state.destination == Destination.Notes && !state.searching) {
if (state.filterable) {
FilterRow(
filters = state.filters,
onOpen = { filtering = true },
@@ -257,13 +254,7 @@ fun BoardScreen(
onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
// The whole board in one order, as on the web: not a
// search, another view, or a filtered subset, whose
// order would be renumbered against notes it can't see.
reorderable =
state.destination == Destination.Notes &&
!state.searching &&
state.filters.count == 0,
reorderable = state.reorderable,
onReorder = onReorder,
)
}
@@ -85,6 +85,18 @@ data class BoardState(
) {
/** Search overrides the destination while there is a query to run. */
val searching: Boolean get() = query.isNotBlank()
/**
* The main board only, and not while searching, as on the web: a search spans
* everything, so a filter over its hits would be a control that does nothing.
*/
val filterable: Boolean get() = destination == Destination.Notes && !searching
/**
* The whole board in one order, as on the web: not a search, another view, or a
* filtered subset, whose order would be renumbered against notes it can't see.
*/
val reorderable: Boolean get() = filterable && filters.count == 0
}
/**
@@ -108,75 +108,7 @@ fun NoteCard(
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// TAGS FIRST, on a row of their own above the body: a board is scanned, and
// what a note is about should be the first thing the eye lands on. Not beside
// the body, whose first line is the note's name.
//
// Only labels whose text is not still in the note (`via_tag` is false). A tag
// left in prose is tinted in place instead (see [tintTags]), so this row holds
// what the body cannot say: a tag lifted off its own line, or one added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
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 (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) {
// A note shared with us to view has inert boxes (#5175): ticking one is
// changing its text, which is not ours to do.
NoteBody(
note = note,
onToggleItem = if (note.access == NoteAccess.VIEW) INERT_TOGGLE else 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))
}
}
// Under the words and the links, like the web's card: a photo is often what a
// note is ABOUT, but its first line is still its name.
if (note.attachments.isNotEmpty()) {
CardAttachments(attachments = note.attachments)
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank() && note.previews.isEmpty() && note.attachments.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
SharedChip(note)
CardContents(note = note, onToggleItem = onToggleItem)
}
NoteMenu(
@@ -189,6 +121,83 @@ fun NoteCard(
}
}
/** What a card shows, top to bottom: tags, body, links, attachments, reminder, sharing. */
@Composable
private fun CardContents(
note: Note,
onToggleItem: (Int, Boolean) -> Unit,
) {
// TAGS FIRST, on a row of their own above the body: a board is scanned, and
// what a note is about should be the first thing the eye lands on. Not beside
// the body, whose first line is the note's name.
//
// Only labels whose text is not still in the note (`via_tag` is false). A tag
// left in prose is tinted in place instead (see [tintTags]), so this row holds
// what the body cannot say: a tag lifted off its own line, or one added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
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 (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) {
// A note shared with us to view has inert boxes (#5175): ticking one is
// changing its text, which is not ours to do.
NoteBody(
note = note,
onToggleItem = if (note.access == NoteAccess.VIEW) INERT_TOGGLE else 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))
}
}
// Under the words and the links, like the web's card: a photo is often what a
// note is ABOUT, but its first line is still its name.
if (note.attachments.isNotEmpty()) {
CardAttachments(attachments = note.attachments)
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank() && note.previews.isEmpty() && note.attachments.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
SharedChip(note)
}
/**
* What you can do to a note without opening it, on a long press.
*