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 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..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 @@ -370,7 +370,8 @@ interface SheafApiService { suspend fun listJournals( @Query("member_id") memberId: String? = null, @Query("system_only") systemOnly: Boolean? = null, - @Query("before") before: String? = null, + @Query("pinned") pinned: Boolean? = null, + @Query("cursor") 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 @@ -1069,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") 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"