Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -392,6 +393,15 @@ interface SheafApiService {
@Body body: JournalEntryDeleteConfirm = JournalEntryDeleteConfirm(),
): Response<JournalEntryDeletePending>

@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<ContentRevisionRead>

Expand Down Expand Up @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("") }
Expand All @@ -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. " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading