Rail and drawer: only the 5 tags most recently used or viewed (#5931)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 1m9s
CI & Build / integration (push) Successful in 2m10s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Failing after 6m3s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m5s
Desktop (Tauri) / Update manifest (push) Successful in 4s

A long tag list pushed the drawer's Archive, Trash and Sync off screen on
Android, where Tags still sat above them (#5915 only reordered the web rail).
Tags now come last on Android too, and both clients list the five tags most
recently used or viewed, by name, with "All tags (N)" opening the rest in
place.

Used: the newest untrashed note carrying the tag. GET /api/labels and the
core's list_labels return it as last_used, and each client also folds in the
notes it holds, so a tag applied a moment ago counts before the next listing.
Viewed: opening the tag's lens, remembered on the device (per user on the web).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 14:41:33 -04:00
co-authored by Claude Opus 5.5
parent 74d0d3d88e
commit ea831d1857
14 changed files with 428 additions and 21 deletions
@@ -0,0 +1,31 @@
package com.fabledsword.inkwell
import android.content.Context
/**
* When each tag's lens was last opened on this phone, for the drawer's recent tags
* (#5931). Opening a tag counts as using it, alongside the notes that carry it.
*
* Kept on the device rather than synced: it is a convenience about where this
* person's thumb goes on this screen, and losing it costs one tap.
*/
class TagViews(
context: Context,
) {
// applicationContext, as in SyncSettings: this must not hold an Activity.
private val prefs =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
fun all(): Map<String, Long> = prefs.all.mapNotNull { (id, at) -> (at as? Long)?.let { id to it } }.toMap()
/** Records a view now and returns every view, this one included. */
fun record(tagId: String): Map<String, Long> {
val now = System.currentTimeMillis()
prefs.edit().putLong(tagId, now).apply()
return all() + (tagId to now)
}
private companion object {
const val FILE = "inkwell-tag-views"
}
}
@@ -60,6 +60,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -68,11 +69,13 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.TagViews
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import kotlinx.coroutines.launch
@@ -96,6 +99,15 @@ fun BoardScreen(
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// Opening a tag's lens counts as using it, however it was opened: from the
// drawer, or a tag on a card (#5931).
val context = LocalContext.current
val tagViews = remember(context) { TagViews(context) }
var views by remember { mutableStateOf(tagViews.all()) }
LaunchedEffect(state.destination) {
(state.destination as? Destination.WithLabel)?.let { views = tagViews.record(it.id) }
}
val snackbars = remember { SnackbarHostState() }
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
@@ -143,6 +155,7 @@ fun BoardScreen(
NavigationDrawer(
current = state.destination,
labels = state.labels,
recency = tagRecency(state.labels, state.notes, views),
syncSummary = sync.summary,
onOpen = {
onOpen(it)
@@ -401,11 +414,16 @@ private fun SearchBar(
private fun NavigationDrawer(
current: Destination,
labels: List<Label>,
recency: Map<String, Long>,
syncSummary: String?,
onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
) {
// Every tag, once asked for; until then the few in use (#5931).
var allTags by rememberSaveable { mutableStateOf(false) }
val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name })
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
@@ -418,6 +436,27 @@ private fun NavigationDrawer(
DrawerRow(destination, current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
DrawerRow(destination, current, onOpen)
}
// Sync sits below the divider with the destinations rather than behind
// a settings gear: it is not a preference, it is where you go to find
// out whether this phone and your desktop are actually in step. The
// subtitle answers that without opening it.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.sync_title)) },
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
selected = false,
onClick = onOpenSync,
modifier = Modifier.padding(horizontal = 12.dp),
)
// Tags last, as on the web (#5915): it is the one list here that grows
// without limit, so everything with a fixed place sits above it.
//
// The header renders even with no tags, unlike the rows below it: the
// manage screen is where you go to MAKE the first one, and hiding the
// way in until one exists would be a door that appears only once you
@@ -445,27 +484,28 @@ private fun NavigationDrawer(
)
}
}
labels.forEach { label ->
shown.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
DrawerRow(destination, current, onOpen)
if (labels.size > RECENT_TAG_COUNT) {
NavigationDrawerItem(
label = {
Text(
text =
if (allTags) {
stringResource(R.string.tags_show_fewer)
} else {
stringResource(R.string.tags_show_all, labels.size)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
selected = false,
onClick = { allTags = !allTags },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
// Sync sits below the divider with the destinations rather than behind
// a settings gear: it is not a preference, it is where you go to find
// out whether this phone and your desktop are actually in step. The
// subtitle answers that without opening it.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.sync_title)) },
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
selected = false,
onClick = onOpenSync,
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}
}
@@ -0,0 +1,52 @@
package com.fabledsword.inkwell.ui
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import java.time.OffsetDateTime
/**
* The drawer lists the few tags in use rather than every tag (#5931), so a long
* list never pushes Archive, Trash and Sync out of reach. Mirrors
* `frontend/src/notes/recentTags.ts`.
*/
const val RECENT_TAG_COUNT = 5
/**
* The [count] most recent of [tags], listed by name so a row does not move under
* the thumb that tapped it. Every tag while there are no more than [count].
*/
fun <T> recentTags(
tags: List<T>,
latest: (T) -> Long,
name: (T) -> String,
count: Int = RECENT_TAG_COUNT,
): List<T> {
if (tags.size <= count) return tags
return tags
.sortedWith(compareByDescending(latest).thenBy(String.CASE_INSENSITIVE_ORDER, name))
.take(count)
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, name))
}
/**
* How recently each tag was used or viewed, in epoch milliseconds: the later of its
* newest note in the store ([Label.lastUsed]), its newest note on the board now —
* so a tag applied a moment ago counts before the labels are next listed — and the
* last time its lens was opened here ([views]).
*/
fun tagRecency(
labels: List<Label>,
notes: List<Note>,
views: Map<String, Long>,
): Map<String, Long> {
val latest = labels.associate { it.id to maxOf(epochMillis(it.lastUsed), views[it.id] ?: 0L) }.toMutableMap()
for (note in notes) {
val at = epochMillis(note.updatedAt)
for (label in note.labels) latest.computeIfPresent(label.id) { _, was -> maxOf(was, at) }
}
return latest
}
/** 0 for a missing or unreadable stamp: such a tag simply ranks last. */
fun epochMillis(stamp: String?): Long =
stamp?.let { runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() } ?: 0L
@@ -133,6 +133,8 @@
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string>
<string name="tags_show_all">All tags (%1$d)</string>
<string name="tags_show_fewer">Fewer tags</string>
<string name="tags_title">Tags</string>
<string name="tags_back">Back</string>
<string name="tags_new_hint">New tag</string>
@@ -0,0 +1,41 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
/** The same cases as `frontend/src/notes/recentTags.test.ts`. */
class RecentTagsTest {
private data class Tag(
val name: String,
val at: Long,
)
private fun recent(tags: List<Tag>) = recentTags(tags, { it.at }, { it.name }).map { it.name }
@Test
fun `shows every tag while there are no more than five`() {
val tags = listOf(Tag("c", 0), Tag("a", 0), Tag("b", 0))
assertSame(tags, recentTags(tags, { it.at }, { it.name }))
}
@Test
fun `keeps the five used most recently, listed by name`() {
val tags = listOf("a", "b", "c", "d", "e", "f", "g").mapIndexed { i, n -> Tag(n, i.toLong()) }
assertEquals(listOf("c", "d", "e", "f", "g"), recent(tags))
}
@Test
fun `fills with tags never used, by name, when fewer than five have been`() {
val tags = listOf(Tag("f", 10), Tag("e", 0), Tag("d", 0), Tag("c", 0), Tag("B", 0), Tag("a", 0))
assertEquals(listOf("a", "B", "c", "d", "f"), recent(tags))
}
@Test
fun `reads both stamp forms the store holds`() {
assertEquals(1_760_000_000_000L, epochMillis("2025-10-09T08:53:20.000Z"))
assertEquals(1_760_000_000_000L, epochMillis("2025-10-09T08:53:20+00:00"))
assertEquals(0L, epochMillis(null))
assertEquals(0L, epochMillis("not a time"))
}
}