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
29 changes: 29 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 @@ -296,6 +296,19 @@ data class SystemSafetySettings(
@Json(name = "applies_to_journals") val appliesToJournals: Boolean,
@Json(name = "applies_to_images") val appliesToImages: Boolean,
@Json(name = "applies_to_revisions") val appliesToRevisions: Boolean = false,
@Json(name = "applies_to_notifications") val appliesToNotifications: Boolean = false,
@Json(name = "applies_to_reminders") val appliesToReminders: Boolean = false,
@Json(name = "applies_to_polls") val appliesToPolls: Boolean = false,
@Json(name = "applies_to_messages") val appliesToMessages: Boolean = false,
// Deleting a relationship TYPE, which takes every edge drawn with it. The
// per-edge visibility controls are a separate thing.
@Json(name = "applies_to_relationships") val appliesToRelationships: Boolean = false,
// No grace-able pending action behind this one: it only gates whether
// archiving a member needs re-auth.
@Json(name = "applies_to_archive") val appliesToArchive: Boolean = false,
// Armed by default server-side; the default here matches so an instance that
// predates the category doesn't read as unarmed.
@Json(name = "applies_to_profile_visibility") val appliesToProfileVisibility: Boolean = true,
@Json(name = "auto_pin_first_revision") val autoPinFirstRevision: Boolean = true,
)

Expand All @@ -311,6 +324,13 @@ data class SystemSafetyUpdate(
@Json(name = "applies_to_journals") val appliesToJournals: Boolean? = null,
@Json(name = "applies_to_images") val appliesToImages: Boolean? = null,
@Json(name = "applies_to_revisions") val appliesToRevisions: Boolean? = null,
@Json(name = "applies_to_notifications") val appliesToNotifications: Boolean? = null,
@Json(name = "applies_to_reminders") val appliesToReminders: Boolean? = null,
@Json(name = "applies_to_polls") val appliesToPolls: Boolean? = null,
@Json(name = "applies_to_messages") val appliesToMessages: Boolean? = null,
@Json(name = "applies_to_relationships") val appliesToRelationships: Boolean? = null,
@Json(name = "applies_to_archive") val appliesToArchive: Boolean? = null,
@Json(name = "applies_to_profile_visibility") val appliesToProfileVisibility: Boolean? = null,
@Json(name = "auto_pin_first_revision") val autoPinFirstRevision: Boolean? = null,
val password: String? = null,
@Json(name = "totp_code") val totpCode: String? = null,
Expand Down Expand Up @@ -342,11 +362,20 @@ data class SafetyChangeRequestRead(
val status: String,
)

// A staged raise-to-public waiting out the grace window. Count and time only,
// no entity label, matching the pending-delete banner.
@JsonClass(generateAdapter = true)
data class PendingExposureRead(
val kind: String,
@Json(name = "activates_at") val activatesAt: String,
)

@JsonClass(generateAdapter = true)
data class SystemSafetyResponse(
val settings: SystemSafetySettings,
@Json(name = "pending_actions") val pendingActions: List<PendingActionRead>,
@Json(name = "pending_changes") val pendingChanges: List<SafetyChangeRequestRead>,
@Json(name = "pending_exposures") val pendingExposures: List<PendingExposureRead> = emptyList(),
)

@JsonClass(generateAdapter = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ fun HomeScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
val pendingExposuresCount = state.pendingSafetyExposures.size
if (pendingExposuresCount > 0) {
val earliest = state.pendingSafetyExposures.mapNotNull { parseFinalize(it.activatesAt) }.minOrNull()
SafetyPendingBanner(
kind = SafetyBannerKind.EXPOSURES,
count = pendingExposuresCount,
earliestFinalize = earliest,
onClick = onNavigateToSystemSafety,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
state.pendingTrimNotice?.let { notice ->
TrimNoticePendingBanner(
notice = notice,
Expand Down Expand Up @@ -612,7 +623,7 @@ private fun SwitchFrontSheet(

// ── System Safety pending banner ──────────────────────────────────────────────

private enum class SafetyBannerKind { ACTIONS, CHANGES }
private enum class SafetyBannerKind { ACTIONS, CHANGES, EXPOSURES }

@Composable
private fun SafetyPendingBanner(
Expand Down Expand Up @@ -664,6 +675,9 @@ private fun safetyBannerMessage(kind: SafetyBannerKind, count: Int, earliest: Of
SafetyBannerKind.CHANGES ->
if (count == 1) "Safety settings change pending — finalizes $time."
else "$count safety settings changes pending — next finalizes $time."
SafetyBannerKind.EXPOSURES ->
if (count == 1) "1 change that makes something public takes effect $time."
else "$count changes that make something public. Next takes effect $time."
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import systems.lupine.sheaf.data.model.FrontUpdate
import systems.lupine.sheaf.data.model.GroupRead
import systems.lupine.sheaf.data.model.MemberRead
import systems.lupine.sheaf.data.model.PendingActionRead
import systems.lupine.sheaf.data.model.PendingExposureRead
import systems.lupine.sheaf.data.model.SafetyChangeRequestRead
import systems.lupine.sheaf.data.model.SystemRead
import systems.lupine.sheaf.data.model.UserRead
Expand Down Expand Up @@ -77,6 +78,7 @@ data class HomeUiState(
val pendingOpCount: Int = 0,
val pendingSafetyActions: List<PendingActionRead> = emptyList(),
val pendingSafetyChanges: List<SafetyChangeRequestRead> = emptyList(),
val pendingSafetyExposures: List<PendingExposureRead> = emptyList(),
// Pending revision-retention trim notice from /v1/retention. Set when the
// server has a status="pending" notice (typically a tier downgrade).
val pendingTrimNotice: systems.lupine.sheaf.data.model.RetentionTrimNoticeRead? = null,
Expand Down Expand Up @@ -348,6 +350,7 @@ class HomeViewModel @Inject constructor(
it.copy(
pendingSafetyActions = safetyResp.pendingActions,
pendingSafetyChanges = safetyResp.pendingChanges,
pendingSafetyExposures = safetyResp.pendingExposures,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import systems.lupine.sheaf.data.model.PendingActionRead
import systems.lupine.sheaf.data.model.PendingExposureRead
import systems.lupine.sheaf.data.model.SafetyChangeRequestRead
import systems.lupine.sheaf.data.model.SystemSafetySettings
import systems.lupine.sheaf.ui.components.*
Expand Down Expand Up @@ -102,14 +103,7 @@ fun SystemSafetyScreen(
SectionHeader("Apply safety to")
CategoryToggles(
draft = draft,
onMembers = { viewModel.updateDraft { copy(appliesToMembers = it) } },
onGroups = { viewModel.updateDraft { copy(appliesToGroups = it) } },
onTags = { viewModel.updateDraft { copy(appliesToTags = it) } },
onFields = { viewModel.updateDraft { copy(appliesToFields = it) } },
onFronts = { viewModel.updateDraft { copy(appliesToFronts = it) } },
onJournals = { viewModel.updateDraft { copy(appliesToJournals = it) } },
onImages = { viewModel.updateDraft { copy(appliesToImages = it) } },
onRevisions = { viewModel.updateDraft { copy(appliesToRevisions = it) } },
onChange = { next -> viewModel.updateDraft { next } },
)

SectionHeader("Revision history")
Expand Down Expand Up @@ -178,6 +172,16 @@ fun SystemSafetyScreen(
)
}
}

if (state.pendingExposures.isNotEmpty()) {
SectionHeader("Pending public changes")
state.pendingExposures.forEach { exposure ->
PendingExposureRow(
exposure = exposure,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
}
}

Expand Down Expand Up @@ -359,38 +363,72 @@ private fun AuthTierRowSupportingText(needsTotp: Boolean, totpEnabled: Boolean)
@Composable
private fun CategoryToggles(
draft: SystemSafetySettings,
onMembers: (Boolean) -> Unit,
onGroups: (Boolean) -> Unit,
onTags: (Boolean) -> Unit,
onFields: (Boolean) -> Unit,
onFronts: (Boolean) -> Unit,
onJournals: (Boolean) -> Unit,
onImages: (Boolean) -> Unit,
onRevisions: (Boolean) -> Unit,
onChange: (SystemSafetySettings) -> Unit,
) {
data class CategoryRow(val label: String, val supporting: String?, val value: Boolean, val onChange: (Boolean) -> Unit)
data class CategoryRow(
val label: String,
val supporting: String?,
val value: Boolean,
val apply: (Boolean) -> SystemSafetySettings,
)
val items = listOf(
CategoryRow("Members", null, draft.appliesToMembers, onMembers),
CategoryRow("Groups", null, draft.appliesToGroups, onGroups),
CategoryRow("Tags", null, draft.appliesToTags, onTags),
CategoryRow("Custom fields", null, draft.appliesToFields, onFields),
CategoryRow("Fronts", null, draft.appliesToFronts, onFronts),
CategoryRow("Journal entries", null, draft.appliesToJournals, onJournals),
CategoryRow("Images", null, draft.appliesToImages, onImages),
CategoryRow("Members", null, draft.appliesToMembers) { draft.copy(appliesToMembers = it) },
CategoryRow("Groups", null, draft.appliesToGroups) { draft.copy(appliesToGroups = it) },
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("Images", null, draft.appliesToImages) { draft.copy(appliesToImages = it) },
CategoryRow(
"Pinned revisions",
"Require re-auth and grace before unpinning",
draft.appliesToRevisions,
onRevisions,
),
) { draft.copy(appliesToRevisions = it) },
CategoryRow(
"Notifications",
"Deleting a channel or revoking a watcher",
draft.appliesToNotifications,
) { draft.copy(appliesToNotifications = it) },
CategoryRow(
"Reminders",
"Deleting a reminder",
draft.appliesToReminders,
) { draft.copy(appliesToReminders = it) },
CategoryRow(
"Polls",
"Deleting a poll, with its votes and audit log",
draft.appliesToPolls,
) { draft.copy(appliesToPolls = it) },
CategoryRow(
"Messages",
"Deleting a board message or thread",
draft.appliesToMessages,
) { draft.copy(appliesToMessages = it) },
CategoryRow(
"Relationships",
"Deleting a relationship type, which takes every relationship drawn with it",
draft.appliesToRelationships,
) { draft.copy(appliesToRelationships = it) },
CategoryRow(
"Archive members",
"Re-auth to archive a member. No grace period, speed-bump only.",
draft.appliesToArchive,
) { draft.copy(appliesToArchive = it) },
CategoryRow(
"Profile visibility",
"Gates making things more visible rather than deleting them: publishing a " +
"share view, adding someone to one that is already shared, or raising " +
"anything to public. With no grace period it just asks for re-auth first.",
draft.appliesToProfileVisibility,
) { draft.copy(appliesToProfileVisibility = it) },
)
Column {
items.forEachIndexed { index, row ->
ListItem(
headlineContent = { Text(row.label) },
supportingContent = row.supporting?.let { { Text(it) } },
trailingContent = {
Switch(checked = row.value, onCheckedChange = row.onChange)
Switch(checked = row.value, onCheckedChange = { onChange(row.apply(it)) })
},
)
if (index != items.lastIndex) HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
Expand Down Expand Up @@ -506,6 +544,32 @@ private fun PendingChangeRow(
}
}

// Read-only: un-exposing is immediate and ungated, but it happens where the
// change was made (the share view, the member, the relationship), not here.
@Composable
private fun PendingExposureRow(
exposure: PendingExposureRead,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(formatExposureKind(exposure.kind), style = MaterialTheme.typography.bodyMedium)
Text(
"Becomes public ${formatRelativeTime(exposure.activatesAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

@Composable
private fun ReauthDialog(
authTier: String,
Expand Down Expand Up @@ -592,6 +656,13 @@ private fun formatField(field: String): String = when (field) {
"applies_to_journals" -> "journal entries"
"applies_to_images" -> "images"
"applies_to_revisions" -> "pinned revisions"
"applies_to_notifications" -> "notifications"
"applies_to_reminders" -> "reminders"
"applies_to_polls" -> "polls"
"applies_to_messages" -> "messages"
"applies_to_relationships" -> "relationships"
"applies_to_archive" -> "archiving members"
"applies_to_profile_visibility" -> "profile visibility"
"auto_pin_first_revision" -> "auto-pin first revision"
else -> field
}
Expand All @@ -605,9 +676,27 @@ private fun formatActionType(type: String): String = when (type) {
"journal_delete" -> "Delete journal"
"image_delete" -> "Delete image"
"revision_unpin" -> "Unpin revision"
"watch_token_revoke" -> "Revoke watcher"
"channel_delete" -> "Delete channel"
"reminder_delete" -> "Delete reminder"
"poll_delete" -> "Delete poll"
"message_delete" -> "Delete message"
"message_thread_delete" -> "Delete thread"
"relationship_type_delete" -> "Delete relationship type"
else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() }
}

private fun formatExposureKind(kind: String): String = when (kind) {
"system_privacy" -> "System set to public"
"member_privacy" -> "Member raised to public"
"member_fronting" -> "Member's fronting made visible"
"group_privacy" -> "Group raised to public"
"field_privacy" -> "Custom field raised to public"
"relationship_privacy" -> "Relationship raised to public"
"view_flags" -> "Share view showing more"
else -> kind.replace('_', ' ').replaceFirstChar { it.uppercase() }
}

private fun formatChangesSummary(changes: Map<String, Any?>): String {
if (changes.isEmpty()) return "Pending change"
return changes.entries.joinToString("; ") { (k, v) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch
import retrofit2.HttpException
import systems.lupine.sheaf.data.api.SheafApiService
import systems.lupine.sheaf.data.model.PendingActionRead
import systems.lupine.sheaf.data.model.PendingExposureRead
import systems.lupine.sheaf.data.model.SafetyChangeRequestRead
import systems.lupine.sheaf.data.model.SystemSafetySettings
import systems.lupine.sheaf.data.model.SystemSafetyUpdate
Expand All @@ -25,6 +26,7 @@ data class SystemSafetyUiState(
val draft: SystemSafetySettings? = null,
val pendingActions: List<PendingActionRead> = emptyList(),
val pendingChanges: List<SafetyChangeRequestRead> = emptyList(),
val pendingExposures: List<PendingExposureRead> = emptyList(),
val totpEnabled: Boolean = false,
val isSaving: Boolean = false,
val saveError: String? = null,
Expand Down Expand Up @@ -64,6 +66,7 @@ class SystemSafetyViewModel @Inject constructor(
draft = resp.settings,
pendingActions = resp.pendingActions,
pendingChanges = resp.pendingChanges,
pendingExposures = resp.pendingExposures,
totpEnabled = user?.totpEnabled == true,
)
}
Expand Down Expand Up @@ -197,6 +200,13 @@ class SystemSafetyViewModel @Inject constructor(
appliesToJournals = draft.appliesToJournals.takeIf { it != current.appliesToJournals },
appliesToImages = draft.appliesToImages.takeIf { it != current.appliesToImages },
appliesToRevisions = draft.appliesToRevisions.takeIf { it != current.appliesToRevisions },
appliesToNotifications = draft.appliesToNotifications.takeIf { it != current.appliesToNotifications },
appliesToReminders = draft.appliesToReminders.takeIf { it != current.appliesToReminders },
appliesToPolls = draft.appliesToPolls.takeIf { it != current.appliesToPolls },
appliesToMessages = draft.appliesToMessages.takeIf { it != current.appliesToMessages },
appliesToRelationships = draft.appliesToRelationships.takeIf { it != current.appliesToRelationships },
appliesToArchive = draft.appliesToArchive.takeIf { it != current.appliesToArchive },
appliesToProfileVisibility = draft.appliesToProfileVisibility.takeIf { it != current.appliesToProfileVisibility },
autoPinFirstRevision = draft.autoPinFirstRevision.takeIf { it != current.autoPinFirstRevision },
password = password?.ifBlank { null },
totpCode = totpCode?.ifBlank { null },
Expand All @@ -222,6 +232,13 @@ class SystemSafetyViewModel @Inject constructor(
if (current.appliesToJournals && !draft.appliesToJournals) return true
if (current.appliesToImages && !draft.appliesToImages) return true
if (current.appliesToRevisions && !draft.appliesToRevisions) return true
if (current.appliesToNotifications && !draft.appliesToNotifications) return true
if (current.appliesToReminders && !draft.appliesToReminders) return true
if (current.appliesToPolls && !draft.appliesToPolls) return true
if (current.appliesToMessages && !draft.appliesToMessages) return true
if (current.appliesToRelationships && !draft.appliesToRelationships) return true
if (current.appliesToArchive && !draft.appliesToArchive) return true
if (current.appliesToProfileVisibility && !draft.appliesToProfileVisibility) return true
if (current.autoPinFirstRevision && !draft.autoPinFirstRevision) return true
return false
}
Expand Down
Loading
Loading