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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ data class MessageRead(
@Json(name = "body") val body: String,
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
// 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,
)

@JsonClass(generateAdapter = true)
Expand Down
21 changes: 21 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 @@ -430,6 +430,9 @@ data class MemberRead(
// endpoint still returns archived members, so the client filters them
// out of the main roster and surfaces them separately.
@Json(name = "archived_at") val archivedAt: String? = null,
// 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,
) {
val displayNameOrName: String get() = displayName?.takeIf { it.isNotBlank() } ?: name
val isArchived: Boolean get() = archivedAt != null
Expand Down Expand Up @@ -500,6 +503,9 @@ data class FrontRead(
// Member ids whose member_since hit the server-side walk-back depth cap.
// Their timestamp is a lower bound; UI should prefix with "> ".
@Json(name = "member_since_capped") val memberSinceCapped: List<String> = emptyList(),
// 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,
)

@JsonClass(generateAdapter = true)
Expand Down Expand Up @@ -541,6 +547,9 @@ data class GroupRead(
@Json(name = "parent_id") val parentId: String?,
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
// 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,
)

@JsonClass(generateAdapter = true)
Expand Down Expand Up @@ -574,6 +583,9 @@ data class TagRead(
val color: String?,
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
// 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,
)

@JsonClass(generateAdapter = true)
Expand Down Expand Up @@ -621,6 +633,9 @@ data class CustomFieldRead(
val privacy: String,
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
// 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,
) {
val fieldTypeDisplay: String get() = fieldType.replaceFirstChar { it.uppercase() }
val privacyDisplay: String get() = privacy.replaceFirstChar { it.uppercase() }
Expand Down Expand Up @@ -1465,6 +1480,9 @@ data class JournalEntryRead(
@Json(name = "author_member_names") val authorMemberNames: List<String> = emptyList(),
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
// 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,
)

@JsonClass(generateAdapter = true)
Expand All @@ -1481,6 +1499,9 @@ data class JournalEntryReadWithCount(
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "revision_count") val revisionCount: Int = 0,
// 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,
)

@JsonClass(generateAdapter = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ data class WatchTokenRead(
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "channel_count") val channelCount: Int = 0,
// 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,
)

// ── Layer-2 / Layer-3 rule specs ─────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ data class PollRead(
@Json(name = "votes") val votes: List<PollVoteRead>? = null,
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
// 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,
)

@JsonClass(generateAdapter = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,7 @@ data class ReminderRead(
@Json(name = "last_fired_at") val lastFiredAt: String? = null,
@Json(name = "pending_count") val pendingCount: Int = 0,
@Json(name = "next_fire_at") val nextFireAt: String? = null,
// 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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package systems.lupine.sheaf.ui.components

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import systems.lupine.sheaf.ui.theme.LocalWarningColors
import java.time.Duration
import java.time.OffsetDateTime
import java.time.format.DateTimeParseException

/**
* System Safety queues destructive actions behind a grace period instead of
* doing them immediately, so an entity can be alive on screen and already
* scheduled for deletion. These helpers are the shared vocabulary for saying so
* consistently wherever such a thing is listed.
*/

/** Parse a `pending_delete_at` / `finalize_after` timestamp, null-safe. */
fun parseFinalizeAt(iso: String?): OffsetDateTime? =
if (iso.isNullOrBlank()) null
else try {
OffsetDateTime.parse(iso)
} catch (_: DateTimeParseException) {
null
}

/**
* How long until [target], phrased for a badge: "in 18h", "in 3 days".
*
* Hours below a day, days above it: a grace period is configured in days, and
* "in 47 hours" is harder to act on than "in 2 days".
*
* Rounds **down** throughout. This is a deadline for undoing something
* destructive, so the two rounding errors are not equal: telling someone they
* have 2 days when 25 hours remain can cost them the window, while telling them
* 1 day when 25 hours remain only makes them act sooner. Below an hour there is
* no floor left to give, so it says so in words rather than showing "in 0h".
*
* Already-elapsed windows read as "any moment" rather than a negative, since the
* sweep that finalises them runs on its own schedule and a row can briefly
* outlive its own deadline.
*/
fun formatFinalizeCountdown(target: OffsetDateTime): String {
val duration = Duration.between(OffsetDateTime.now(), target)
if (duration.isNegative || duration.isZero) return "any moment"
val hours = duration.toHours()
if (hours < 1) return "in under an hour"
if (hours < 24) return "in ${hours}h"
val days = duration.toDays()
return if (days == 1L) "in 1 day" else "in $days days"
}

/** Opacity for a row whose entity is pending deletion, mirroring web. */
const val PENDING_DELETE_ALPHA = 0.6f

/**
* Badge for an entity awaiting a queued delete: "Deletes in 18h".
*
* Renders nothing when [pendingDeleteAt] is null, so it can be dropped
* unconditionally into any row. Deliberately not clickable: these sit inside
* cards that are themselves clickable, and a tap target inside a tap target is
* a coin toss. Cancelling lives where it already did, in Settings > Safety,
* which the Home banner deep-links to.
*/
@Composable
fun PendingDeleteBadge(
pendingDeleteAt: String?,
modifier: Modifier = Modifier,
) {
val target = parseFinalizeAt(pendingDeleteAt) ?: return
val warning = LocalWarningColors.current
val countdown = formatFinalizeCountdown(target)
Surface(
color = warning.container,
contentColor = warning.onContainer,
shape = RoundedCornerShape(50),
// The icon is decorative and the text already says everything; merge
// the whole badge into one announcement instead of two fragments.
modifier = modifier.clearAndSetSemantics {
contentDescription = "Pending delete, finalises $countdown"
},
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Outlined.Schedule,
contentDescription = null,
modifier = Modifier.size(13.dp),
)
Text(
text = " Deletes $countdown",
style = MaterialTheme.typography.labelSmall,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import systems.lupine.sheaf.data.model.CustomFieldRead
import systems.lupine.sheaf.ui.components.*
import androidx.compose.ui.draw.alpha

// ── Helpers ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -183,7 +184,10 @@ private fun FieldListItem(
Text(field.name, style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall)
Column {
Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall)
PendingDeleteBadge(field.pendingDeleteAt)
}
},
leadingContent = {
Icon(
Expand Down Expand Up @@ -215,7 +219,10 @@ private fun FieldListItem(
}
}
},
modifier = Modifier.fillMaxWidth().clickable { onClick() },
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.alpha(if (field.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f),
)
HorizontalDivider()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope
import systems.lupine.sheaf.ui.components.*
import systems.lupine.sheaf.ui.relationships.REL_SCOPE_GROUP
import systems.lupine.sheaf.ui.relationships.RelationshipsEditor
import androidx.compose.ui.draw.alpha

// ── Group list card ───────────────────────────────────────────────────────────

Expand All @@ -46,11 +47,15 @@ internal fun GroupCard(
onEdit: () -> Unit,
) {
val accent = parseColor(group.color ?: "#534AB7") ?: MaterialTheme.colorScheme.primary
val pending = group.pendingDeleteAt != null
Card(
onClick = onToggleExpand,
// Indent subgroups under their parent. Capped so deep nesting stays
// usable on a narrow screen.
modifier = Modifier.fillMaxWidth().padding(start = (minOf(depth, 4) * 16).dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = (minOf(depth, 4) * 16).dp)
.alpha(if (pending) PENDING_DELETE_ALPHA else 1f),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
Expand Down Expand Up @@ -83,6 +88,10 @@ internal fun GroupCard(
overflow = TextOverflow.Ellipsis,
)
}
PendingDeleteBadge(
group.pendingDeleteAt,
modifier = Modifier.padding(top = 4.dp),
)
}
IconButton(onClick = onEdit) {
Icon(
Expand Down Expand Up @@ -223,6 +232,7 @@ fun GroupDetailScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.error != null) ErrorBanner(state.error!!)
PendingDeleteBadge(state.group?.pendingDeleteAt)

OutlinedTextField(
value = form.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ import java.time.format.DateTimeFormatter
import java.time.format.TextStyle as JTimeTextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale
import androidx.compose.ui.draw.alpha
import systems.lupine.sheaf.ui.components.PENDING_DELETE_ALPHA
import systems.lupine.sheaf.ui.components.PendingDeleteBadge

@OptIn(ExperimentalFoundationApi::class)
@Composable
Expand Down Expand Up @@ -602,10 +605,14 @@ private fun FrontHistoryCard(
) {
val isActive = front.endedAt == null
Card(
modifier = Modifier.fillMaxWidth().combinedClickable(onClick = onClick, onLongClick = onLongClick),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.alpha(if (front.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp)) {
PendingDeleteBadge(front.pendingDeleteAt)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
Expand Down
25 changes: 6 additions & 19 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ private fun SafetyPendingBanner(
}

private fun safetyBannerMessage(kind: SafetyBannerKind, count: Int, earliest: OffsetDateTime?): String {
val time = earliest?.let { formatRelativeFinalize(it) } ?: "soon"
val time = earliest?.let { formatFinalizeCountdown(it) } ?: "soon"
return when (kind) {
SafetyBannerKind.ACTIONS ->
if (count == 1) "1 pending destructive action — finalizes $time."
Expand All @@ -680,23 +680,10 @@ private fun safetyBannerMessage(kind: SafetyBannerKind, count: Int, earliest: Of
}
}

private fun formatRelativeFinalize(target: OffsetDateTime): String {
val duration = Duration.between(OffsetDateTime.now(), target)
if (duration.isNegative || duration.isZero) return "any moment"
val hours = duration.toHours()
if (hours < 24) {
val h = (duration.toMinutes() + 59) / 60
return "in ${h}h"
}
val days = (duration.toMinutes() + 24 * 60 - 1) / (24 * 60)
return if (days == 1L) "in 1 day" else "in $days days"
}

private fun parseFinalize(iso: String): OffsetDateTime? = try {
OffsetDateTime.parse(iso)
} catch (_: DateTimeParseException) {
null
}
// Countdown + parsing live in ui/components/PendingDelete.kt: the banner here
// and the per-row badges describe the same deadline, so they share one
// implementation rather than drifting apart.
private fun parseFinalize(iso: String): OffsetDateTime? = parseFinalizeAt(iso)

// ── Retention trim-notice banner ──────────────────────────────────────────────

Expand Down Expand Up @@ -730,7 +717,7 @@ private fun TrimNoticePendingBanner(
tint = onContainerColor,
modifier = Modifier.size(20.dp),
)
val time = effectiveAt?.let { formatRelativeFinalize(it) } ?: "soon"
val time = effectiveAt?.let { formatFinalizeCountdown(it) } ?: "soon"
Text(
"Plan downgrade trim pending: revisions over the new tier limits will be pruned $time. Tap to review.",
style = MaterialTheme.typography.bodyMedium,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import systems.lupine.sheaf.ui.components.*
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import androidx.compose.ui.draw.alpha

// ── Journals list ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -180,10 +181,13 @@ private fun JournalCard(
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.alpha(if (entry.pendingDeleteAt != null) PENDING_DELETE_ALPHA else 1f),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
PendingDeleteBadge(entry.pendingDeleteAt)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
entry.title?.takeIf { it.isNotBlank() } ?: "Untitled",
Expand Down Expand Up @@ -327,6 +331,7 @@ fun JournalDetailScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.error != null) ErrorBanner(state.error!!)
PendingDeleteBadge(state.entry?.pendingDeleteAt)

if (state.isEditing) {
JournalEditor(
Expand Down
Loading
Loading