From dc1d0b6681021fbdd54ae663993e8e71b9076b5d Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:47:11 -0400 Subject: [PATCH 1/3] feat(journals): pin entries, with unpinning under Journals safety An entry can be pinned or unpinned from the top bar of its page. Pinned entries are listed in their own section above the rest, loaded in one request with pinned=true while the paged list asks for pinned=false, the same split the web app uses. With the Journal entries safety category on and a grace period set, unpinning asks for re-auth and queues a journal_unpin action instead. The entry then shows when the unpin will finalize, and the pending action has a label on the System Safety page. A server without pinning ignores the filter and returns everything in both requests, so only entries that actually carry pinned_at go in the pinned section. --- .../lupine/sheaf/data/api/SheafApiService.kt | 10 ++ .../systems/lupine/sheaf/data/model/Models.kt | 18 +++ .../sheaf/ui/components/RevisionPinning.kt | 5 +- .../sheaf/ui/journals/JournalsScreen.kt | 99 +++++++++++- .../sheaf/ui/journals/JournalsViewModel.kt | 142 ++++++++++++++++-- .../sheaf/ui/settings/SystemSafetyScreen.kt | 7 +- 6 files changed, 266 insertions(+), 15 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index 94ab569..c06a480 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -370,6 +370,7 @@ interface SheafApiService { suspend fun listJournals( @Query("member_id") memberId: String? = null, @Query("system_only") systemOnly: Boolean? = null, + @Query("pinned") pinned: Boolean? = null, @Query("before") before: String? = null, @Query("limit") limit: Int = 50, ): JournalListResponse @@ -392,6 +393,15 @@ interface SheafApiService { @Body body: JournalEntryDeleteConfirm = JournalEntryDeleteConfirm(), ): Response + @POST("/v1/journals/{id}/pin") + suspend fun pinJournal(@Path("id") id: String): JournalEntryRead + + @POST("/v1/journals/{id}/unpin") + suspend fun unpinJournal( + @Path("id") id: String, + @Body body: JournalEntryUnpinConfirm = JournalEntryUnpinConfirm(), + ): JournalEntryUnpinResponse + @GET("/v1/journals/{id}/revisions") suspend fun listJournalRevisions(@Path("id") id: String): List diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 7a432be..67bb1e9 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -1650,6 +1650,8 @@ data class JournalEntryRead( // Set when a System Safety grace period has this queued for deletion. // Still returned and still usable until the window closes; the UI marks it. @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, + @Json(name = "pinned_at") val pinnedAt: String? = null, + @Json(name = "pending_unpin_at") val pendingUnpinAt: String? = null, ) @JsonClass(generateAdapter = true) @@ -1669,6 +1671,8 @@ data class JournalEntryReadWithCount( // Mirrors JournalEntryRead: the detail screen reads this variant, so the // field has to exist on both or the entry looks safe once you open it. @Json(name = "pending_delete_at") val pendingDeleteAt: String? = null, + @Json(name = "pinned_at") val pinnedAt: String? = null, + @Json(name = "pending_unpin_at") val pendingUnpinAt: String? = null, ) @JsonClass(generateAdapter = true) @@ -1706,6 +1710,20 @@ data class JournalEntryDeletePending( @Json(name = "finalize_after") val finalizeAfter: String, ) +@JsonClass(generateAdapter = true) +data class JournalEntryUnpinConfirm( + val password: String? = null, + @Json(name = "totp_code") val totpCode: String? = null, +) + +/** Same shape as [UnpinRevisionResponse]: `entry` when immediate, pending fields when queued. */ +@JsonClass(generateAdapter = true) +data class JournalEntryUnpinResponse( + val entry: JournalEntryRead? = null, + @Json(name = "pending_action_id") val pendingActionId: String? = null, + @Json(name = "finalize_after") val finalizeAfter: String? = null, +) + @JsonClass(generateAdapter = true) data class ContentRevisionRead( val id: String, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/RevisionPinning.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/RevisionPinning.kt index cbe5f2f..e8cc9e9 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/RevisionPinning.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/RevisionPinning.kt @@ -48,6 +48,7 @@ fun RevisionUnpinDialog( errorMessage: String?, onConfirm: (password: String?, totpCode: String?) -> Unit, onDismiss: () -> Unit, + queuedMessage: String? = null, ) { var password by remember { mutableStateOf("") } var totpCode by remember { mutableStateOf("") } @@ -62,7 +63,9 @@ fun RevisionUnpinDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - if (willQueue) { + if (willQueue && queuedMessage != null) { + queuedMessage + } else if (willQueue) { "Unpinning will be queued for ${safety.gracePeriodDays} " + "${if (safety.gracePeriodDays == 1) "day" else "days"} before " + "the revision becomes eligible for the rolling history sweep. " + diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsScreen.kt index 59c1608..c18abe7 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsScreen.kt @@ -109,14 +109,14 @@ fun JournalsScreen( state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - state.error != null && state.entries.isEmpty() -> Column( + state.error != null && state.entries.isEmpty() && state.pinned.isEmpty() -> Column( Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { ErrorBanner(state.error!!) Button(onClick = { viewModel.load() }) { Text("Retry") } } - state.entries.isEmpty() -> EmptyState( + state.entries.isEmpty() && state.pinned.isEmpty() -> EmptyState( icon = Icons.AutoMirrored.Outlined.MenuBook, title = "No journal entries yet", subtitle = "Tap + to write your first entry.", @@ -130,6 +130,20 @@ fun JournalsScreen( ), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + if (state.pinned.isNotEmpty()) { + item(key = "pinned-header") { ListSectionHeader("Pinned", Icons.Outlined.PushPin) } + // Prefixed so an entry caught mid-unpin in both lists can't collide on key. + items(state.pinned, key = { "pinned:${it.id}" }) { entry -> + JournalCard( + entry = entry, + member = entry.memberId?.let { state.members[it] }, + onClick = { onEntryClick(entry.id) }, + ) + } + if (state.entries.isNotEmpty()) { + item(key = "entries-header") { ListSectionHeader("Entries", null) } + } + } items(state.entries, key = { it.id }) { entry -> JournalCard( entry = entry, @@ -173,6 +187,29 @@ private fun FilterRow( } } +@Composable +private fun ListSectionHeader(title: String, icon: androidx.compose.ui.graphics.vector.ImageVector?) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + if (icon != null) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable private fun JournalCard( entry: JournalEntryRead, @@ -189,6 +226,15 @@ private fun JournalCard( Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { PendingDeleteBadge(entry.pendingDeleteAt) Row(verticalAlignment = Alignment.CenterVertically) { + if (entry.pinnedAt != null) { + Icon( + Icons.Outlined.PushPin, + contentDescription = if (entry.pendingUnpinAt != null) "Pinned, unpin scheduled" else "Pinned", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(4.dp)) + } Text( entry.title?.takeIf { it.isNotBlank() } ?: "Untitled", style = MaterialTheme.typography.titleMedium, @@ -320,6 +366,22 @@ fun JournalDetailScreen( IconButton(onClick = { viewModel.toggleRevisions() }) { Icon(Icons.Default.History, contentDescription = "Revisions") } + val entry = state.entry!! + if (entry.pinnedAt != null) { + IconButton( + onClick = { viewModel.requestUnpinEntry() }, + enabled = !state.isPinningEntry && entry.pendingUnpinAt == null, + ) { + Icon(Icons.Filled.PushPin, contentDescription = "Unpin") + } + } else { + IconButton( + onClick = { viewModel.pinEntry() }, + enabled = !state.isPinningEntry, + ) { + Icon(Icons.Outlined.PushPin, contentDescription = "Pin") + } + } IconButton(onClick = { showDeleteDialog = true }) { Icon( Icons.Default.Delete, @@ -605,6 +667,19 @@ fun JournalDetailScreen( } } + if (state.showEntryUnpinDialog) { + val days = state.entrySafety.gracePeriodDays + RevisionUnpinDialog( + safety = state.entrySafety, + isUnpinning = state.isPinningEntry, + errorMessage = state.entryUnpinError, + onConfirm = { pwd, code -> viewModel.unpinEntry(pwd, code) }, + onDismiss = { viewModel.dismissEntryUnpinDialog() }, + queuedMessage = "The entry will stay pinned for $days ${if (days == 1) "day" else "days"} " + + "before it's unpinned. You can cancel from System Safety before then.", + ) + } + if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, @@ -661,6 +736,26 @@ private fun JournalReader( ) } } + if (entry.pinnedAt != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + Icons.Outlined.PushPin, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + entry.pendingUnpinAt?.let { + "Pinned ยท unpins ${formatJournalDate(it, LocalDisplayTimeZone.current)}" + } ?: "Pinned", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } if (attachedMember != null) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { MemberAvatar(attachedMember, size = 28.dp) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsViewModel.kt index b4725e2..78cd50c 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/journals/JournalsViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,6 +20,7 @@ import systems.lupine.sheaf.data.model.JournalEntryCreate import systems.lupine.sheaf.data.model.JournalEntryDeletePending import systems.lupine.sheaf.data.model.JournalEntryRead import systems.lupine.sheaf.data.model.JournalEntryReadWithCount +import systems.lupine.sheaf.data.model.JournalEntryUnpinConfirm import systems.lupine.sheaf.data.model.JournalEntryUpdate import systems.lupine.sheaf.data.model.MemberRead import systems.lupine.sheaf.data.model.PinRevisionRequest @@ -32,7 +35,11 @@ import javax.inject.Inject enum class JournalFilter { ALL, SYSTEM_ONLY } +// Pinned entries load in one request above the paginated list; 200 is the API's max page. +private const val PINNED_LIMIT = 200 + data class JournalsUiState( + val pinned: List = emptyList(), val entries: List = emptyList(), val members: Map = emptyMap(), val filter: JournalFilter = JournalFilter.ALL, @@ -59,34 +66,46 @@ class JournalsViewModel @Inject constructor( fun setFilter(filter: JournalFilter) { if (filter == _state.value.filter) return - _state.update { it.copy(filter = filter, entries = emptyList(), nextCursor = null) } + _state.update { it.copy(filter = filter, pinned = emptyList(), entries = emptyList(), nextCursor = null) } load() } fun load() { viewModelScope.launch { - _state.update { it.copy(isLoading = it.entries.isEmpty(), error = null) } + _state.update { it.copy(isLoading = it.entries.isEmpty() && it.pinned.isEmpty(), error = null) } val online = networkMonitor.isOnline.first() if (!online) { - val cached = cache.getJournals() + val cached = cache.getJournals().orEmpty() _state.update { it.copy( - entries = cached.orEmpty(), + pinned = cached.filter { e -> e.pinnedAt != null }, + entries = cached.filter { e -> e.pinnedAt == null }, isLoading = false, ) } return@launch } val filter = _state.value.filter + val systemOnly = if (filter == JournalFilter.SYSTEM_ONLY) true else null runCatching { - api.listJournals( - systemOnly = if (filter == JournalFilter.SYSTEM_ONLY) true else null, - ) + coroutineScope { + val pinnedReq = async { + runCatching { + api.listJournals(systemOnly = systemOnly, pinned = true, limit = PINNED_LIMIT) + }.getOrNull()?.items.orEmpty() + } + val resp = api.listJournals(systemOnly = systemOnly, pinned = false) + pinnedReq.await() to resp + } } - .onSuccess { resp -> - if (filter == JournalFilter.ALL) cache.saveJournals(resp.items) + .onSuccess { (pinnedItems, resp) -> + // A server without pinning ignores the filter and sends every + // entry both times, so only keep what is actually pinned. + val pinned = pinnedItems.filter { it.pinnedAt != null } + if (filter == JournalFilter.ALL) cache.saveJournals(pinned + resp.items) _state.update { it.copy( + pinned = pinned, entries = resp.items, nextCursor = resp.nextCursor, isLoading = false, @@ -96,12 +115,18 @@ class JournalsViewModel @Inject constructor( .onFailure { e -> val cached = if (filter == JournalFilter.ALL) cache.getJournals() else null if (cached != null) { - _state.update { it.copy(entries = cached, isLoading = false) } + _state.update { + it.copy( + pinned = cached.filter { x -> x.pinnedAt != null }, + entries = cached.filter { x -> x.pinnedAt == null }, + isLoading = false, + ) + } } else { _state.update { s -> s.copy( isLoading = false, - error = if (s.entries.isEmpty()) e.toUserMessage() else s.error, + error = if (s.entries.isEmpty() && s.pinned.isEmpty()) e.toUserMessage() else s.error, ) } } @@ -118,6 +143,7 @@ class JournalsViewModel @Inject constructor( runCatching { api.listJournals( systemOnly = if (filter == JournalFilter.SYSTEM_ONLY) true else null, + pinned = false, before = cursor, ) } @@ -173,6 +199,10 @@ data class JournalDetailUiState( val pendingRevisionId: String? = null, val pinError: String? = null, val unpinQueued: Boolean = false, + val isPinningEntry: Boolean = false, + val entrySafety: RevisionSafety = RevisionSafety(), + val showEntryUnpinDialog: Boolean = false, + val entryUnpinError: String? = null, ) @HiltViewModel @@ -420,6 +450,96 @@ class JournalDetailViewModel @Inject constructor( } } + fun pinEntry() { + val id = entryId ?: return + _state.update { it.copy(isPinningEntry = true, error = null) } + viewModelScope.launch { + runCatching { api.pinJournal(id) } + .onSuccess { updated -> + _state.update { + it.copy( + isPinningEntry = false, + entry = it.entry?.copy(pinnedAt = updated.pinnedAt, pendingUnpinAt = updated.pendingUnpinAt), + ) + } + } + .onFailure { e -> + _state.update { it.copy(isPinningEntry = false, error = e.toUserMessage("Failed to pin entry")) } + } + } + } + + /** + * Safety is read at tap time: with the journals category armed the unpin + * needs re-auth and gets queued, otherwise it goes straight through. + */ + fun requestUnpinEntry() { + if (entryId == null) return + _state.update { it.copy(isPinningEntry = true, error = null) } + viewModelScope.launch { + val safety = runCatching { + val x = api.getSystemSafety() + val user = runCatching { api.getMe() }.getOrNull() + // RevisionSafety only needs to know whether the covering category + // is on; for entry unpins that is the journals one. + RevisionSafety( + authTier = x.settings.authTier, + totpEnabled = user?.totpEnabled == true, + appliesToRevisions = x.settings.appliesToJournals, + gracePeriodDays = x.settings.gracePeriodDays, + ) + }.getOrNull() + if (safety?.willQueueUnpin == true) { + _state.update { + it.copy(isPinningEntry = false, entrySafety = safety, showEntryUnpinDialog = true) + } + } else { + unpinEntry() + } + } + } + + fun unpinEntry(password: String? = null, totpCode: String? = null) { + val id = entryId ?: return + _state.update { it.copy(isPinningEntry = true, entryUnpinError = null) } + viewModelScope.launch { + runCatching { + api.unpinJournal(id, JournalEntryUnpinConfirm(password?.ifBlank { null }, totpCode?.ifBlank { null })) + } + .onSuccess { resp -> + _state.update { st -> + val updated = resp.entry + st.copy( + isPinningEntry = false, + showEntryUnpinDialog = false, + entry = if (updated != null) { + st.entry?.copy(pinnedAt = updated.pinnedAt, pendingUnpinAt = null) + } else { + st.entry?.copy(pendingUnpinAt = resp.finalizeAfter) + }, + ) + } + } + .onFailure { e -> + _state.update { st -> + if (st.showEntryUnpinDialog) { + val msg = if (e is retrofit2.HttpException && e.code() in listOf(400, 401)) + "Incorrect password or authenticator code" + else + e.toUserMessage("Failed to unpin entry") + st.copy(isPinningEntry = false, entryUnpinError = msg) + } else { + st.copy(isPinningEntry = false, error = e.toUserMessage("Failed to unpin entry")) + } + } + } + } + } + + fun dismissEntryUnpinDialog() { + _state.update { it.copy(showEntryUnpinDialog = false, entryUnpinError = null) } + } + fun clearPinError() { _state.update { it.copy(pinError = null) } } fun clearUnpinQueued() { _state.update { it.copy(unpinQueued = false) } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyScreen.kt index daa6765..bb67a58 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyScreen.kt @@ -377,7 +377,11 @@ private fun CategoryToggles( CategoryRow("Tags", null, draft.appliesToTags) { draft.copy(appliesToTags = it) }, CategoryRow("Custom fields", null, draft.appliesToFields) { draft.copy(appliesToFields = it) }, CategoryRow("Fronts", null, draft.appliesToFronts) { draft.copy(appliesToFronts = it) }, - CategoryRow("Journal entries", null, draft.appliesToJournals) { draft.copy(appliesToJournals = it) }, + CategoryRow( + "Journal entries", + "Deleting or unpinning an entry", + draft.appliesToJournals, + ) { draft.copy(appliesToJournals = it) }, CategoryRow("Images", null, draft.appliesToImages) { draft.copy(appliesToImages = it) }, CategoryRow( "Pinned revisions", @@ -674,6 +678,7 @@ private fun formatActionType(type: String): String = when (type) { "field_delete" -> "Delete field" "front_delete" -> "Delete front" "journal_delete" -> "Delete journal" + "journal_unpin" -> "Unpin journal" "image_delete" -> "Delete image" "revision_unpin" -> "Unpin revision" "watch_token_revoke" -> "Revoke watcher" From 7fa56b673c118488e15ceee0ef883f37dfd40d36 Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:47:12 -0400 Subject: [PATCH 2/3] fix(journals): send the page cursor as cursor, not before The list endpoint reads the cursor from `cursor`, so loading more entries asked for the first page again. --- .../java/systems/lupine/sheaf/data/api/SheafApiService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index c06a480..77b7f40 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -371,7 +371,7 @@ interface SheafApiService { @Query("member_id") memberId: String? = null, @Query("system_only") systemOnly: Boolean? = null, @Query("pinned") pinned: Boolean? = null, - @Query("before") before: String? = null, + @Query("cursor") before: String? = null, @Query("limit") limit: Int = 50, ): JournalListResponse @@ -1079,7 +1079,7 @@ interface SheafApiService { @Query("board_member_id") boardMemberId: String? = null, @Query("caller_member_id") callerMemberId: String? = null, @Query("limit") limit: Int = 100, - @Query("before") before: String? = null, + @Query("cursor") before: String? = null, ): MessagesPage @POST("/v1/messages") From a3df55fe4baf4793fc8589b492d559443df85713 Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:02:47 -0400 Subject: [PATCH 3/3] docs: changelog for journal pinning --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f4098..70bda96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to the Sheaf Android client are recorded here. Format loosel follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses semantic versioning (`MAJOR.MINOR.PATCH`). +## [Unreleased] + +### Added + +- **Pin journal entries.** The pin button at the top of an entry keeps it in a + Pinned section above the rest of the journal list. With the Journal entries + toggle in System Safety on, unpinning asks you to re-authenticate and waits + out the grace period, and the entry shows when the unpin will go through. + You can cancel it from System Safety like any other queued action. Needs a + server running 1.6.0 or later. + +### Fixed + +- **Scrolling further down the journal list loads older entries.** Loading + more asked the server for the first page again, so the list never got past + its first 50 entries. + ## [1.4.1] - 2026-09-20 ### Changed