From 29fffde8de4157081be43a9c0460ddc8e60f2ef9 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:03:04 -0400 Subject: [PATCH 1/4] feat: give the system a banner, and say who can see each custom field Two additions the web client picked up this week. The system profile gains a banner, the exact twin of a member's: same 3:1 shape, same cropper, same upload endpoint and purpose tag, sitting above the avatar in the same place the member editor puts it. The control itself moves into a shared BannerPicker rather than being written twice, since two copies of a thing this fiddly drift. Custom fields now say who can see them where their values are edited and shown. The level lives on the definition and applies to every member, which is exactly why the member editor could not answer "wait, is that one public?" on its own: you had to leave the member, open Settings, find the field and come back, which is a poor thing to have to do before typing something sensitive into a box. The tag appears under each input in the editor, beside each value on the member profile, and on the field list in Settings, from one component so the three cannot disagree. Display only, as on web: changing a level stays in Settings, where the raise runs the step-up and grace-period flow. Where a raise is staged, all three surfaces now say what it will become and when, which needed pending_privacy and privacy_activates_at adding to the field model. Rendered as a tinted pill rather than web's tinted word: bare coloured text has to stay legible across seventeen palettes in light and dark, and the pill is what this app already uses for the pending-delete badge. Public borrows that badge's warning colours, the app's existing "visible, or about to be" tone. --- .../sheaf/data/api/PatchJsonAdapters.kt | 1 + .../systems/lupine/sheaf/data/model/Models.kt | 8 + .../lupine/sheaf/ui/avatar/BannerPicker.kt | 161 ++++++++++++++++++ .../lupine/sheaf/ui/components/PrivacyTag.kt | 92 ++++++++++ .../sheaf/ui/fields/CustomFieldsScreen.kt | 14 +- .../sheaf/ui/members/CustomFieldEditor.kt | 11 ++ .../lupine/sheaf/ui/members/MembersScreen.kt | 128 +++----------- .../sheaf/ui/settings/SettingsScreen.kt | 9 + .../sheaf/ui/settings/SystemEditViewModel.kt | 32 ++++ 9 files changed, 342 insertions(+), 114 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/avatar/BannerPicker.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/PrivacyTag.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt index 851dce6..dc56b64 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt @@ -140,6 +140,7 @@ class SystemUpdateJsonAdapter(moshi: Moshi) : JsonAdapter() { clears("description", value.description) clears("tag", value.tag) clears("avatar_url", value.avatarUrl) + clears("banner_url", value.bannerUrl) clears("color", value.color) clears("note", value.note) omitsWhenNull("password", value.password) 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 d91298e..c62b0b4 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 @@ -266,6 +266,9 @@ data class SystemRead( val description: String?, val tag: String?, @Json(name = "avatar_url") val avatarUrl: String?, + // Wide 3:1 header image, the exact twin of a member's. Absent on servers + // older than 1.6.0, which is why it defaults rather than being required. + @Json(name = "banner_url") val bannerUrl: String? = null, val color: String?, val privacy: String, // A raise of the master switch waiting out the grace window. `privacy` @@ -308,6 +311,7 @@ data class SystemUpdate( val description: String? = null, val tag: String? = null, @Json(name = "avatar_url") val avatarUrl: String? = null, + @Json(name = "banner_url") val bannerUrl: String? = null, val color: String? = null, val privacy: String? = null, val note: String? = null, @@ -799,6 +803,10 @@ data class CustomFieldRead( val options: CustomFieldOptions? = null, val order: Int, val privacy: String, + // A raise waiting out a System Safety grace period. `privacy` above is + // still what the field actually is until privacyActivatesAt passes. + @Json(name = "pending_privacy") val pendingPrivacy: String? = null, + @Json(name = "privacy_activates_at") val privacyActivatesAt: String? = 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. diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/avatar/BannerPicker.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/avatar/BannerPicker.kt new file mode 100644 index 0000000..97241e6 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/avatar/BannerPicker.kt @@ -0,0 +1,161 @@ +package systems.lupine.sheaf.ui.avatar + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +/** + * The wide 3:1 header image control: pick, crop, upload, remove. + * + * Shared because a system's banner is the exact twin of a member's, down to + * the aspect ratio and the storage path, and a control that exists twice + * drifts. Owns its own menu, picker and crop dialog; the caller owns the + * upload, because that is where the form state lives. + */ +@Composable +fun BannerPicker( + bannerUrl: String?, + isUploading: Boolean, + onPickedBytes: (ByteArray) -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + var pendingCropUri by remember { mutableStateOf(null) } + + val pickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> pendingCropUri = uri } + + pendingCropUri?.let { uri -> + BannerCropDialog( + sourceUri = uri, + onCancel = { pendingCropUri = null }, + onConfirm = { bytes -> + pendingCropUri = null + onPickedBytes(bytes) + }, + ) + } + + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(3f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { showMenu = true }, + contentAlignment = Alignment.Center, + ) { + if (!bannerUrl.isNullOrEmpty()) { + AsyncImage( + model = bannerUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text("Add banner", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .size(28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { showMenu = true }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit banner", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + + if (isUploading) { + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(strokeWidth = 3.dp) + } + } + + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text("Choose photo") }, + leadingIcon = { Icon(Icons.Default.Image, contentDescription = null) }, + onClick = { + showMenu = false + pickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + ) + if (!bannerUrl.isNullOrEmpty()) { + DropdownMenuItem( + text = { Text("Remove banner", color = MaterialTheme.colorScheme.error) }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + showMenu = false + onRemove() + }, + ) + } + } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/PrivacyTag.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/PrivacyTag.kt new file mode 100644 index 0000000..380b59b --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/PrivacyTag.kt @@ -0,0 +1,92 @@ +package systems.lupine.sheaf.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import systems.lupine.sheaf.ui.theme.LocalWarningColors +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Who can see a thing, as a small word beside it. + * + * A word rather than an icon, because a badge on its own says nothing for + * private, and private is the answer people open a screen to confirm before + * typing something sensitive into it. + * + * One tone per level, so the answer is readable at a glance: private is the + * quiet default, the two levels that put something in front of somebody get a + * colour, and public gets the loud one. Public borrows the same warning pair + * the pending-delete badge uses, which is this app's "visible, or about to + * be" colour and is defined per palette for light and dark. A level from a + * newer server falls back to quiet rather than to nothing. + */ +@Composable +fun PrivacyTag(level: String, modifier: Modifier = Modifier) { + val warning = LocalWarningColors.current + val (container: Color, content: Color) = when (level) { + "public" -> warning.container to warning.onContainer + "friends" -> MaterialTheme.colorScheme.secondaryContainer to + MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.surfaceContainerHighest to + MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + color = container, + contentColor = content, + shape = RoundedCornerShape(50), + modifier = modifier, + ) { + Text( + privacyLevelLabel(level), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } +} + +/** The vocabulary, in one place so every surface says the same words. */ +fun privacyLevelLabel(level: String): String = when (level) { + "private" -> "Private" + "friends" -> "Friends only" + "public" -> "Public" + else -> level.replaceFirstChar { it.uppercase() } +} + +/** + * The line under a privacy control when a raise is waiting out a System Safety + * grace period: what it will become, and when. + * + * Renders nothing when nothing is staged, so a call site can drop it in + * unconditionally. + */ +@Composable +fun StagedPrivacyNote( + pendingPrivacy: String?, + activatesAt: String?, + modifier: Modifier = Modifier, +) { + if (pendingPrivacy.isNullOrBlank() || activatesAt.isNullOrBlank()) return + val zone = LocalDisplayTimeZone.current + Text( + "Staged: becomes ${privacyLevelLabel(pendingPrivacy)} on ${formatStagedDate(activatesAt, zone)}.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} + +private val stagedDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("MMM d, yyyy · HH:mm") + +private fun formatStagedDate(iso: String, zone: ZoneId): String = runCatching { + OffsetDateTime.parse(iso).atZoneSameInstant(zone).toLocalDateTime().format(stagedDateFormatter) +}.getOrDefault(iso) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt index 786dd17..7217fa2 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt @@ -218,6 +218,7 @@ private fun FieldListItem( supportingContent = { Column { Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall) + StagedPrivacyNote(field.pendingPrivacy, field.privacyActivatesAt) PendingDeleteBadge(field.pendingDeleteAt) } }, @@ -233,15 +234,10 @@ private fun FieldListItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - SuggestionChip( - onClick = {}, - label = { - Text( - field.privacyDisplay, - style = MaterialTheme.typography.labelSmall, - ) - }, - ) + // The same tag the member editor and the member profile show, + // so the three places a field's level appears cannot disagree + // about what it is or what it is called. + PrivacyTag(field.privacy) // This order is what a member's profile and any shared page // show their fields in, so it is worth being able to set. // See GroupCard: no explicit tint, so the button's own diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/CustomFieldEditor.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/CustomFieldEditor.kt index a3695a2..c89db59 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/CustomFieldEditor.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/CustomFieldEditor.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.ExperimentalLayoutApi import systems.lupine.sheaf.data.model.CustomFieldRead +import systems.lupine.sheaf.ui.components.PrivacyTag +import systems.lupine.sheaf.ui.components.StagedPrivacyNote import systems.lupine.sheaf.ui.components.datePickerDate import systems.lupine.sheaf.ui.components.datePickerMillis import java.time.Instant @@ -75,6 +77,12 @@ internal fun CustomFieldEditor( onChange: (Any?) -> Unit, ) { val choices = field.options?.choices + // The level lives on the definition and applies to every member, which is + // exactly why the editor could not answer "wait, is that one public?" on + // its own: you had to leave the member, open Settings, find the field and + // come back. It sits under the input, where Material puts supporting text, + // so it reads as belonging to the field above it rather than the next one. + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { when (field.fieldType) { "text" -> TextEditor(label = field.name, value = value as? String, onChange = onChange) "number" -> NumberEditor(label = field.name, value = value, onChange = onChange) @@ -98,6 +106,9 @@ internal fun CustomFieldEditor( color = MaterialTheme.colorScheme.error, ) } + PrivacyTag(field.privacy) + StagedPrivacyNote(field.pendingPrivacy, field.privacyActivatesAt) + } } /** Coerce a multiselect server value into List. Defensively diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index ca7f4a0..561dec6 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -431,7 +431,6 @@ fun MemberDetailScreen( val form by viewModel.form.collectAsState() val baseline by viewModel.baselineForm.collectAsState() var showAvatarMenu by remember { mutableStateOf(false) } - var showBannerMenu by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } // Holds the URI of the just-picked image while the cropper dialog // is on screen. Null means no crop in progress. The crop dialog @@ -455,23 +454,6 @@ fun MemberDetailScreen( ) } - // Separate crop pipeline for the wide 3:1 banner. - var pendingBannerCropUri by remember { mutableStateOf(null) } - val bannerPickerLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.PickVisualMedia() - ) { uri -> pendingBannerCropUri = uri } - - pendingBannerCropUri?.let { uri -> - systems.lupine.sheaf.ui.avatar.BannerCropDialog( - sourceUri = uri, - onCancel = { pendingBannerCropUri = null }, - onConfirm = { bytes -> - pendingBannerCropUri = null - viewModel.uploadBannerBytes(bytes) - }, - ) - } - val bioImagePicker = rememberMarkdownImagePicker(viewModel.markdownImages, viewModel.viewModelScope) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) @@ -539,89 +521,12 @@ fun MemberDetailScreen( ) { // Banner (wide 3:1 header, shown on the profile only, not in lists). - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(3f) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { showBannerMenu = true }, - contentAlignment = Alignment.Center, - ) { - if (!form.bannerUrl.isNullOrEmpty()) { - AsyncImage( - model = form.bannerUrl, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Image, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - Text("Add banner", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(8.dp) - .size(28.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable { showBannerMenu = true }, - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.Edit, - contentDescription = "Edit banner", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - - if (state.isUploadingBanner) { - Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.3f)), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(strokeWidth = 3.dp) - } - } - - DropdownMenu( - expanded = showBannerMenu, - onDismissRequest = { showBannerMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Choose photo") }, - leadingIcon = { Icon(Icons.Default.Image, contentDescription = null) }, - onClick = { - showBannerMenu = false - bannerPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - ) - if (!form.bannerUrl.isNullOrEmpty()) { - DropdownMenuItem( - text = { Text("Remove banner", color = MaterialTheme.colorScheme.error) }, - leadingIcon = { - Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) - }, - onClick = { - showBannerMenu = false - viewModel.removeBanner() - }, - ) - } - } - } + systems.lupine.sheaf.ui.avatar.BannerPicker( + bannerUrl = form.bannerUrl, + isUploading = state.isUploadingBanner, + onPickedBytes = { bytes -> viewModel.uploadBannerBytes(bytes) }, + onRemove = { viewModel.removeBanner() }, + ) // Avatar Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { @@ -1198,10 +1103,23 @@ fun MemberProfileScreen( ListItem( headlineContent = { Text(field.name) }, supportingContent = { - Text( - display, - style = MaterialTheme.typography.bodyMedium, - ) + // Value, then who can see it. In the + // supporting slot rather than the + // trailing one: a trailing tag would + // take its width from the name, which + // is how a long field name ended up + // spelling itself down the screen. + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + display, + style = MaterialTheme.typography.bodyMedium, + ) + PrivacyTag(field.privacy) + StagedPrivacyNote( + field.pendingPrivacy, + field.privacyActivatesAt, + ) + } }, colors = itemColors, ) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt index 11add41..bdb3641 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt @@ -708,6 +708,15 @@ fun SystemEditScreen( ) { if (state.error != null) ErrorBanner(state.error!!) + // Banner, above the avatar: the same control a member's uses, and + // the same place it sits on the member editor. + systems.lupine.sheaf.ui.avatar.BannerPicker( + bannerUrl = form.bannerUrl, + isUploading = state.isUploadingBanner, + onPickedBytes = { bytes -> viewModel.uploadBannerBytes(bytes) }, + onRemove = { viewModel.removeBanner() }, + ) + // Avatar picker Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt index 660e8be..4f52c52 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt @@ -26,6 +26,7 @@ data class SystemEditForm( val note: String = "", val tag: String = "", val avatarUrl: String = "", + val bannerUrl: String = "", val color: String = "", val privacy: String = "private", val showMemberCreatedDate: Boolean = false, @@ -35,6 +36,7 @@ data class SystemEditUiState( val isLoading: Boolean = true, val isSaving: Boolean = false, val isUploadingAvatar: Boolean = false, + val isUploadingBanner: Boolean = false, val saved: Boolean = false, val error: String? = null, // The master Public switch is a ceiling like any other, so it takes the @@ -81,6 +83,7 @@ class SystemEditViewModel @Inject constructor( note = system.note ?: "", tag = system.tag ?: "", avatarUrl = system.avatarUrl ?: "", + bannerUrl = system.bannerUrl ?: "", color = system.color ?: "", privacy = system.privacy, showMemberCreatedDate = system.showMemberCreatedDate, @@ -131,6 +134,7 @@ class SystemEditViewModel @Inject constructor( note = f.note, tag = f.tag.takeIf { it.isNotBlank() }, avatarUrl = f.avatarUrl.takeIf { it.isNotBlank() }, + bannerUrl = f.bannerUrl.takeIf { it.isNotBlank() }, color = f.color.takeIf { it.isNotBlank() }, privacy = f.privacy, showMemberCreatedDate = f.showMemberCreatedDate, @@ -221,6 +225,34 @@ class SystemEditViewModel @Inject constructor( } } + /** + * Upload a pre-cropped banner. Same endpoint and `purpose=banner` tag as a + * member's, because on the server the two are the same kind of image. + */ + fun uploadBannerBytes(bytes: ByteArray, fileName: String = "banner.png") { + viewModelScope.launch { + _state.update { it.copy(isUploadingBanner = true, error = null) } + runCatching { + val requestBody = bytes.toRequestBody("image/png".toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", fileName, requestBody) + api.uploadFile(part, purpose = "banner") + } + .onSuccess { response -> + _form.update { it.copy(bannerUrl = response.url) } + _state.update { it.copy(isUploadingBanner = false) } + } + .onFailure { e -> + _state.update { + it.copy(isUploadingBanner = false, error = "Failed to upload banner: ${e.toUserMessage()}") + } + } + } + } + + fun removeBanner() { + _form.update { it.copy(bannerUrl = "") } + } + fun removeAvatar() { _form.update { it.copy(avatarUrl = "") } } From 3cfeef2eeb743e6555f1b735d07d832fd654729f Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:08:25 -0400 Subject: [PATCH 2/4] fix: stop offering Public where publishing is turned off An instance with public profiles switched off cannot publish anything, and the API refuses a raise to public with a 403 saying so. All five privacy selects went on offering it anyway, so choosing Public was a guaranteed dead end that surfaced as a bare permissions error with no hint as to why. Members, groups, custom fields, the system profile and relationship edges now render it disabled with one line underneath. Disabled rather than removed: dropping the option would make the feature look absent, which is a different untruth from the one being fixed. Two cases keep it offered, and both matter more than the tidiness of the rule. A record already stored as public keeps it, because that is how its own value displays and, above all, how somebody lowers it again - nothing may stand between a user and reducing their own exposure. And relationship edges between groups are not gated at all, because the public projection never queries them, so the server stores public there as asked and disabling it would invent a restriction that does not exist. The predicate and the wording live next to the existing raise gate, so the five surfaces cannot drift, and the note opens with the same clause as the sharing screen's card rather than inventing a sixth description of one state. --- .../sheaf/ui/fields/CustomFieldsScreen.kt | 23 ++++++++ .../lupine/sheaf/ui/groups/GroupsScreen.kt | 10 ++++ .../lupine/sheaf/ui/members/MembersScreen.kt | 12 ++++ .../ui/relationships/RelationshipsEditor.kt | 19 +++++++ .../sheaf/ui/settings/SettingsScreen.kt | 10 ++++ .../sheaf/ui/settings/SystemEditViewModel.kt | 6 ++ .../lupine/sheaf/ui/sharing/RaiseGate.kt | 29 ++++++++++ .../lupine/sheaf/ui/sharing/RaiseGateTest.kt | 57 +++++++++++++++++++ 8 files changed, 166 insertions(+) create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/sharing/RaiseGateTest.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt index 7217fa2..44c7005 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt @@ -27,6 +27,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 systems.lupine.sheaf.ui.sharing.offersPublic import androidx.compose.ui.draw.alpha // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -142,6 +143,9 @@ fun CustomFieldsScreen( if (showAddSheet) { AddFieldSheet( isSaving = state.isSaving, + // A new field has no stored level, so Public would be a raise from + // nothing, which the server refuses on create too. + offersPublic = state.raiseGate.offersPublic(null), onDismiss = { showAddSheet = false }, onSave = { name, fieldType, privacy, choices -> viewModel.createField(name, fieldType, privacy, choices) @@ -156,6 +160,7 @@ fun CustomFieldsScreen( EditFieldDialog( field = field, isSaving = state.isSaving, + offersPublic = state.raiseGate.offersPublic(field.privacy), onDismiss = { editingField = null }, onSave = { name, privacy, choices -> viewModel.updateField( @@ -340,6 +345,7 @@ private fun ChoicesEditor( @Composable private fun AddFieldSheet( isSaving: Boolean, + offersPublic: Boolean, onDismiss: () -> Unit, onSave: (name: String, fieldType: String, privacy: String, choices: List?) -> Unit, ) { @@ -415,6 +421,7 @@ private fun AddFieldSheet( privacyOptions.forEachIndexed { index, option -> SegmentedButton( selected = privacy == option, + enabled = offersPublic || option != "public", onClick = { privacy = option }, shape = SegmentedButtonDefaults.itemShape( index = index, @@ -425,6 +432,13 @@ private fun AddFieldSheet( } } } + if (!offersPublic) { + Text( + systems.lupine.sheaf.ui.sharing.PUBLISHING_OFF_NOTE, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Button( @@ -457,6 +471,7 @@ private fun AddFieldSheet( private fun EditFieldDialog( field: CustomFieldRead, isSaving: Boolean, + offersPublic: Boolean, onDismiss: () -> Unit, onSave: (name: String, privacy: String, choices: List?) -> Unit, ) { @@ -506,6 +521,7 @@ private fun EditFieldDialog( privacyOptions.forEachIndexed { index, option -> SegmentedButton( selected = privacy == option, + enabled = offersPublic || option != "public", onClick = { privacy = option }, shape = SegmentedButtonDefaults.itemShape( index = index, @@ -516,6 +532,13 @@ private fun EditFieldDialog( } } } + if (!offersPublic) { + Text( + systems.lupine.sheaf.ui.sharing.PUBLISHING_OFF_NOTE, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } }, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt index 9ff69f0..8f841bb 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt @@ -23,6 +23,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewModelScope import systems.lupine.sheaf.ui.components.* +import systems.lupine.sheaf.ui.sharing.offersPublic import systems.lupine.sheaf.ui.relationships.REL_SCOPE_GROUP import systems.lupine.sheaf.ui.relationships.RelationshipsEditor import androidx.compose.ui.draw.alpha @@ -314,15 +315,24 @@ fun GroupDetailScreen( ) SectionHeader("Privacy") + val offersPublic = state.raiseGate.offersPublic(state.group?.privacy) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { listOf("public", "friends", "private").forEachIndexed { index, level -> SegmentedButton( selected = form.privacy == level, + enabled = offersPublic || level != "public", onClick = { viewModel.updateForm { copy(privacy = level) } }, shape = SegmentedButtonDefaults.itemShape(index = index, count = 3), ) { Text(level.replaceFirstChar { it.uppercase() }) } } } + if (!offersPublic) { + Text( + systems.lupine.sheaf.ui.sharing.PUBLISHING_OFF_NOTE, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Text( state.pendingPrivacy?.let { "Staged: this becomes \"$it\" when the grace window passes." diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index 561dec6..e3e0a25 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.launch import systems.lupine.sheaf.data.model.ContentRevisionRead import systems.lupine.sheaf.data.model.MemberRead import systems.lupine.sheaf.ui.components.* +import systems.lupine.sheaf.ui.sharing.offersPublic import systems.lupine.sheaf.ui.relationships.REL_SCOPE_MEMBER import systems.lupine.sheaf.ui.relationships.RelationshipsEditor import java.time.OffsetDateTime @@ -706,15 +707,26 @@ fun MemberDetailScreen( } SectionHeader("Privacy") + // Public stays offered when it is already the stored value, since + // that is how it displays and how it gets lowered again. + val offersPublic = state.raiseGate.offersPublic(state.member?.privacy) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { listOf("public", "friends", "private").forEachIndexed { index, level -> SegmentedButton( selected = form.privacy == level, + enabled = offersPublic || level != "public", onClick = { viewModel.updateForm { copy(privacy = level) } }, shape = SegmentedButtonDefaults.itemShape(index = index, count = 3), ) { Text(level.replaceFirstChar { it.uppercase() }) } } } + if (!offersPublic) { + Text( + systems.lupine.sheaf.ui.sharing.PUBLISHING_OFF_NOTE, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } val staged = state.member?.takeIf { it.pendingPrivacy != null && form.privacy == it.pendingPrivacy } staged?.privacyActivatesAt?.let { at -> Text( diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditor.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditor.kt index 6c78bc5..c690215 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditor.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditor.kt @@ -25,6 +25,7 @@ import systems.lupine.sheaf.data.model.REL_DIRECTION_INCOMING import systems.lupine.sheaf.data.model.REL_DIRECTION_OUTGOING import systems.lupine.sheaf.data.model.RelationshipFromViewpoint import systems.lupine.sheaf.data.model.RelationshipTypeRead +import systems.lupine.sheaf.ui.sharing.offersPublic import systems.lupine.sheaf.data.model.SYMMETRY_DIRECTIONAL import systems.lupine.sheaf.data.model.SYMMETRY_EITHER import systems.lupine.sheaf.data.model.SYMMETRY_SYMMETRIC @@ -80,6 +81,14 @@ fun RelationshipsEditor( rel = rel, otherName = state.nameById[rel.otherId] ?: "Unknown", readOnly = readOnly, + // Group edges are deliberately not gated: the public + // projection never queries them, so the server stores + // public there as asked and disabling it would invent a + // restriction that does not exist. + offersPublic = state.raiseGate.offersPublic( + savedValue = rel.visibility, + gated = scope == REL_SCOPE_MEMBER, + ), onRemove = { viewModel.remove(rel.id) }, onVisibility = { v -> viewModel.setVisibility(rel.id, v) }, ) @@ -130,6 +139,7 @@ private fun RelationshipRow( rel: RelationshipFromViewpoint, otherName: String, readOnly: Boolean, + offersPublic: Boolean, onRemove: () -> Unit, onVisibility: (String) -> Unit, ) { @@ -161,9 +171,18 @@ private fun RelationshipRow( listOf("private", "friends", "public").forEach { level -> androidx.compose.material3.DropdownMenuItem( text = { Text(level.replaceFirstChar { it.uppercase() }) }, + enabled = offersPublic || level != "public", onClick = { menuOpen = false; onVisibility(level) }, ) } + if (!offersPublic) { + Text( + systems.lupine.sheaf.ui.sharing.PUBLISHING_OFF_NOTE, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } } IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = "Remove ${rel.label} relationship with $otherName") diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt index bdb3641..471e7d9 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt @@ -50,6 +50,7 @@ import systems.lupine.sheaf.BuildConfig import systems.lupine.sheaf.ui.auth.AuthViewModel import androidx.lifecycle.viewModelScope import systems.lupine.sheaf.ui.components.* +import systems.lupine.sheaf.ui.sharing.offersPublic @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -844,15 +845,24 @@ fun SystemEditScreen( ) SectionHeader("Privacy") + val offersPublic = state.raiseGate.offersPublic(state.savedPrivacy) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { listOf("public", "friends", "private").forEachIndexed { index, level -> SegmentedButton( selected = form.privacy == level, + enabled = offersPublic || level != "public", onClick = { viewModel.updateForm { copy(privacy = level) } }, shape = SegmentedButtonDefaults.itemShape(index = index, count = 3), ) { Text(level.replaceFirstChar { it.uppercase() }) } } } + if (!offersPublic) { + Text( + systems.lupine.sheaf.ui.sharing.PUBLISHING_OFF_NOTE, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } state.pendingPrivacy?.let { staged -> Text( "Staged: this becomes \"$staged\" when the grace window passes. " + diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt index 4f52c52..4211e3b 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemEditViewModel.kt @@ -47,6 +47,10 @@ data class SystemEditUiState( val stepUpError: String? = null, val pendingPrivacy: String? = null, val privacyActivatesAt: String? = null, + // What the server currently holds, not what the form is showing: a raise + // to public is refused where publishing is off, but a record already + // stored as public must keep the option so it can be lowered again. + val savedPrivacy: String? = null, ) @HiltViewModel @@ -95,6 +99,7 @@ class SystemEditViewModel @Inject constructor( isLoading = false, pendingPrivacy = system.pendingPrivacy, privacyActivatesAt = system.privacyActivatesAt, + savedPrivacy = system.privacy, ) } _state.update { it.copy(raiseGate = api.loadRaiseGate()) } @@ -155,6 +160,7 @@ class SystemEditViewModel @Inject constructor( saveNeedsStepUp = false, pendingPrivacy = updated.pendingPrivacy, privacyActivatesAt = updated.privacyActivatesAt, + savedPrivacy = updated.privacy, ) } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/RaiseGate.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/RaiseGate.kt index ab2a7b5..c115073 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/RaiseGate.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/RaiseGate.kt @@ -39,3 +39,32 @@ suspend fun SheafApiService.loadRaiseGate(): RaiseGate { /** A raise is only gated when it actually widens who can see something. */ fun isRaiseToPublic(current: String?, next: String): Boolean = next == "public" && current != "public" + +/** + * Whether Public may be chosen here at all. + * + * An instance with public profiles switched off refuses a raise to public with + * a 403, so offering it is a guaranteed dead end. Two cases keep it offered + * anyway: + * + * - [savedValue] is already `public`. The server refuses only an actual raise, + * and this is how the stored value still displays and, above all, how + * somebody LOWERS it. Nothing may stand between a user and reducing their + * own exposure. + * - [gated] is false. Relationship edges between groups are never queried by + * the public projection, so the server stores public there as asked; + * disabling it would invent a restriction that does not exist. + */ +fun RaiseGate.offersPublic(savedValue: String?, gated: Boolean = true): Boolean = + publishingAvailable || !gated || savedValue == "public" + +/** + * The one line shown where Public is offered but unavailable. + * + * Opens with the same clause as the sharing screen's card ("Public profiles + * are turned off on this instance"), so somebody who meets this state on two + * screens meets one explanation of it rather than two that almost agree. + */ +const val PUBLISHING_OFF_NOTE: String = + "Public profiles are turned off on this instance, so nothing new can be set to " + + "Public. Anything already public is kept, and you can still lower it." diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/sharing/RaiseGateTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/sharing/RaiseGateTest.kt new file mode 100644 index 0000000..19ffb97 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/sharing/RaiseGateTest.kt @@ -0,0 +1,57 @@ +package systems.lupine.sheaf.ui.sharing + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Whether Public is offered at all. + * + * The rule that matters here is the one about lowering: an instance with + * publishing switched off refuses a raise, and hiding the option is correct + * for everything except a record that is already public, where hiding it would + * take away the only way to make it less visible. + */ +class RaiseGateTest { + + private val publishingOn = RaiseGate(publishingAvailable = true) + private val publishingOff = RaiseGate(publishingAvailable = false) + + @Test fun `with publishing on, everything offers public`() { + assertTrue(publishingOn.offersPublic(null)) + assertTrue(publishingOn.offersPublic("private")) + assertTrue(publishingOn.offersPublic("public")) + } + + @Test fun `with publishing off, a raise is not offered`() { + assertFalse(publishingOff.offersPublic("private")) + assertFalse(publishingOff.offersPublic("friends")) + } + + @Test fun `a record already public keeps the option, so it can be lowered`() { + // Nothing may stand between somebody and reducing their own exposure. + // Hiding Public here would also hide the record's own current value. + assertTrue(publishingOff.offersPublic("public")) + } + + @Test fun `a create form counts as a raise from nothing`() { + // The backend refuses public on the create paths too, so an absent + // stored value is not a licence to offer it. + assertFalse(publishingOff.offersPublic(null)) + } + + @Test fun `an ungated surface is never restricted`() { + // Group relationship edges: the public projection never queries them, + // so the server stores public as asked. Disabling it would invent a + // restriction that does not exist. + assertTrue(publishingOff.offersPublic(null, gated = false)) + assertTrue(publishingOff.offersPublic("private", gated = false)) + } + + @Test fun `the note names the state rather than describing a rule`() { + // It opens with the same clause as the sharing screen's card, so the + // two surfaces are recognisably about one thing. + assertTrue(PUBLISHING_OFF_NOTE.startsWith("Public profiles are turned off on this instance")) + assertTrue("lower it" in PUBLISHING_OFF_NOTE, PUBLISHING_OFF_NOTE) + } +} From 376ddce56fa19a9dd09f29cf075c2e8316addfff Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:15:45 -0400 Subject: [PATCH 3/4] feat(notifications): say when a channel was stopped, and when push cannot work Two states the app had no way to show, both landing in server 1.6.0. A channel whose destination kept failing is switched off by the server after a day. Nothing said so: notifications simply stopped, which is indistinguishable from nothing having happened, and is a bad failure mode for the feature whose job is telling you things. Worse, the recipient-facing label read "Unsubscribed", which tells an owner their recipient opted out when in fact their endpoint died. disabled_reason now reaches both sides. The owner's channel list carries a banner naming the stopped channels and what to check, and the row says "Stopped: deliveries failing" rather than "Disabled", which would read as a choice somebody made. The Receiving tab gets the same distinction. Only server-stopped channels warn: one the owner paused needs no warning, they know, they did it. The channel-type picker offered Mobile push on every instance. On a self-hosted one without credentials you filled the form, pressed Create, and got back a message that read like a setting someone forgot. It is not one: a push credential is paired to an app build rather than to a server, so no amount of configuring reaches the store builds. The option is now disabled where the instance cannot offer it, with the reason folded away behind a question, and the form moves off it so you cannot submit into a refusal. The reason is the server's own words from /v1/notifications/server-config, which exists so every client explains this identically rather than each inventing its own wording. Both features default to today's behaviour on a server too old to be asked. WarningCard moves to the components package, since a second screen now needs it and the warning tone should mean one thing across the app. --- .../lupine/sheaf/data/api/SheafApiService.kt | 4 ++ .../data/model/NotificationChannelModels.kt | 27 +++++++ .../data/model/NotificationRedeemModels.kt | 6 ++ .../lupine/sheaf/ui/components/WarningCard.kt | 67 +++++++++++++++++ .../ui/notifications/ChannelsYouOwnScreen.kt | 45 ++++++++++-- .../notifications/ChannelsYouOwnViewModel.kt | 27 +++++++ .../ui/notifications/CreateChannelScreen.kt | 71 ++++++++++++++++++- .../sheaf/ui/notifications/ReceivingScreen.kt | 8 ++- .../lupine/sheaf/ui/sharing/SharingScreen.kt | 27 +------ 9 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/WarningCard.kt 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 bca2cd5..84571aa 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 @@ -725,6 +725,10 @@ interface SheafApiService { // ── Announcements ──────────────────────────────────────────────────────── + /** What the channel-creation form may offer on this instance. */ + @GET("/v1/notifications/server-config") + suspend fun getNotificationServerConfig(): NotificationServerConfig + @GET("/v1/announcements") suspend fun getAnnouncements(): List diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationChannelModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationChannelModels.kt index b4e617e..b9005e9 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationChannelModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationChannelModels.kt @@ -78,6 +78,12 @@ data class NotificationChannelRead( // ChannelRead.paused_by_sender; defaults preserve compatibility with // older responses that didn't include the field. @Json(name = "paused_by_sender") val pausedBySender: Boolean = false, + // Set when the SERVER switched the channel off, which is a third cause of + // `disabled` alongside the owner pausing it and the recipient + // unsubscribing. Without it, a channel stopped for failing deliveries + // reads as "Unsubscribed", which blames a person for a broken endpoint. + // Only value so far is "delivery_failed". Absent before server 1.6.0. + @Json(name = "disabled_reason") val disabledReason: String? = null, @Json(name = "destination_config") val destinationConfig: Map = emptyMap(), @Json(name = "base_all_members") val baseAllMembers: Boolean = false, @Json(name = "base_include_private") val baseIncludePrivate: Boolean = false, @@ -148,3 +154,24 @@ data class TestDispatchResponse( @Json(name = "delivered") val delivered: Boolean, @Json(name = "error") val error: String? = null, ) + +/** + * What the instance can actually offer the channel-creation form. + * + * `mobile_push.available` is false on an instance holding no push + * credentials, and the reason travels with it rather than being reworded per + * client: a push credential is paired to an app build, not to a server, so + * this is not a setting somebody forgot to fill in and the explanation has to + * say so. Absent before server 1.6.0, where the defaults leave mobile push + * offered exactly as it was. + */ +@JsonClass(generateAdapter = true) +data class NotificationServerConfig( + @Json(name = "mobile_push") val mobilePush: MobilePushAvailability = MobilePushAvailability(), +) + +@JsonClass(generateAdapter = true) +data class MobilePushAvailability( + val available: Boolean = true, + @Json(name = "unavailable_reason") val unavailableReason: String? = null, +) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationRedeemModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationRedeemModels.kt index 660b2a2..d7206c4 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationRedeemModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/NotificationRedeemModels.kt @@ -53,4 +53,10 @@ data class ReceivingChannelView( // chose to leave. Defaults to false so older backend responses parse // the same as before. @Json(name = "paused_by_sender") val pausedBySender: Boolean = false, + // Set when the SERVER switched the channel off, which is a third cause of + // `disabled` alongside the owner pausing it and the recipient + // unsubscribing. Without it, a channel stopped for failing deliveries + // reads as "Unsubscribed", which blames a person for a broken endpoint. + // Only value so far is "delivery_failed". Absent before server 1.6.0. + @Json(name = "disabled_reason") val disabledReason: String? = null, ) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/WarningCard.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/WarningCard.kt new file mode 100644 index 0000000..1bab1d7 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/WarningCard.kt @@ -0,0 +1,67 @@ +package systems.lupine.sheaf.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import systems.lupine.sheaf.ui.theme.LocalWarningColors + +/** + * Something is not working, or is about to become visible, and the reader + * needs to know before they do anything else. + * + * Shared rather than per-screen so the warning tone means one thing across the + * app; the colours come from the palette, so it stays legible in every theme. + * [title] is optional, for the cases where the first line names the thing and + * the rest says what to do about it. + */ +@Composable +fun WarningCard(text: String, modifier: Modifier = Modifier, title: String? = null) { + val warning = LocalWarningColors.current + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = warning.container), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = if (title == null) Alignment.CenterVertically else Alignment.Top, + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = warning.onContainer, + modifier = Modifier.size(20.dp), + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (title != null) { + Text( + title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = warning.onContainer, + ) + } + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = warning.onContainer, + ) + } + } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnScreen.kt index 9be0e3c..4a9ee40 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import systems.lupine.sheaf.data.model.NotificationChannelRead import systems.lupine.sheaf.ui.components.ErrorBanner +import systems.lupine.sheaf.ui.components.WarningCard import systems.lupine.sheaf.ui.components.SheafTopAppBar import androidx.compose.ui.draw.alpha import systems.lupine.sheaf.ui.components.PENDING_DELETE_ALPHA @@ -107,6 +108,32 @@ fun ChannelsYouOwnScreen( ) { CircularProgressIndicator() } state.channels.isEmpty() -> EmptyState() else -> { + // Only the ones the server stopped. A channel the owner + // paused needs no warning: they know, they did it. Named + // rather than counted, because "one of your channels" + // sends you hunting through the list. + val stopped = state.channels.filter { + it.destinationState.equals("disabled", ignoreCase = true) && + it.disabledReason == "delivery_failed" + } + if (stopped.isNotEmpty()) { + WarningCard( + title = if (stopped.size == 1) { + "\"${stopped.first().name}\" stopped sending" + } else { + "${stopped.size} channels stopped sending" + }, + text = if (stopped.size == 1) { + "Deliveries kept failing for a day, so it was switched off. " + + "Check the destination is still reachable, then turn it back on." + } else { + "Deliveries to ${stopped.joinToString(", ") { "\"${it.name}\"" }} " + + "kept failing for a day, so they were switched off. Check each " + + "destination is still reachable, then turn them back on." + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } Text( "Notification channels people can subscribe to for updates from your system.", style = MaterialTheme.typography.bodyMedium, @@ -176,7 +203,7 @@ private fun ChannelRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - stateLabel(channel.destinationState), + stateLabel(channel.destinationState, channel.disabledReason), style = MaterialTheme.typography.bodySmall, color = when { isPending -> MaterialTheme.colorScheme.tertiary @@ -275,9 +302,13 @@ private fun destinationIcon(type: String): ImageVector = when (type.lowercase()) else -> Icons.Outlined.Cloud } -private fun stateLabel(state: String): String = when (state.lowercase()) { - "pending_registration" -> "Pending — share the link to activate" - "active" -> "Active" - "disabled" -> "Disabled" - else -> state -} +private fun stateLabel(state: String, disabledReason: String? = null): String = + when (state.lowercase()) { + "pending_registration" -> "Pending — share the link to activate" + "active" -> "Active" + // The server gave up on this one. "Disabled" would read as a choice + // somebody made, which is the opposite of what happened. + "disabled" -> if (disabledReason == "delivery_failed") "Stopped: deliveries failing" + else "Disabled" + else -> state + } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnViewModel.kt index 5a0eabf..bdf751e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ChannelsYouOwnViewModel.kt @@ -377,6 +377,10 @@ data class CreateChannelUiState( val activationUrl: String? = null, val activationExpiresAt: String? = null, val error: String? = null, + // Defaults leave mobile push offered, which is both the common case and + // the right answer on a server too old to be asked. + val mobilePushAvailable: Boolean = true, + val mobilePushUnavailableReason: String? = null, ) @HiltViewModel @@ -387,6 +391,29 @@ class CreateChannelViewModel @Inject constructor( private val _state = MutableStateFlow(CreateChannelUiState()) val state: StateFlow = _state.asStateFlow() + init { loadServerConfig() } + + /** + * Ask the instance what it can offer before the form is filled in. + * + * A failure leaves the defaults alone: offering a type that then fails at + * submit is the bug being fixed here, but hiding one because a config + * request did not come back would be a worse version of it. + */ + private fun loadServerConfig() { + viewModelScope.launch { + runCatching { api.getNotificationServerConfig() } + .onSuccess { config -> + _state.update { + it.copy( + mobilePushAvailable = config.mobilePush.available, + mobilePushUnavailableReason = config.mobilePush.unavailableReason, + ) + } + } + } + } + fun create( name: String, recipientLabel: String?, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/CreateChannelScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/CreateChannelScreen.kt index 15388a9..3e5bc0a 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/CreateChannelScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/CreateChannelScreen.kt @@ -1,6 +1,13 @@ package systems.lupine.sheaf.ui.notifications import android.content.ClipData +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.TextButton +import androidx.compose.ui.draw.alpha +import androidx.compose.runtime.LaunchedEffect import android.content.ClipboardManager import android.content.Context import android.content.Intent @@ -66,6 +73,12 @@ fun CreateChannelScreen( var name by remember { mutableStateOf("") } var recipientLabel by remember { mutableStateOf("") } var destinationType by remember { mutableStateOf("mobile_push") } + // Never leave the form sitting on an option the instance will refuse. + LaunchedEffect(state.mobilePushAvailable) { + if (!state.mobilePushAvailable && destinationType == "mobile_push") { + destinationType = "web_push" + } + } var triggerOnStart by remember { mutableStateOf(true) } var triggerOnStop by remember { mutableStateOf(false) } var triggerOnCofrontChange by remember { mutableStateOf(false) } @@ -136,7 +149,15 @@ fun CreateChannelScreen( "Delivered to every device on their account.", selected = destinationType == "mobile_push", onSelect = { destinationType = "mobile_push" }, + // Disabled rather than removed, as with a Public + // privacy option an instance cannot serve: dropping it + // would make the feature look absent, which is a + // different wrong answer from the one being fixed. + enabled = state.mobilePushAvailable, ) + if (!state.mobilePushAvailable) { + MobilePushUnavailableNote(state.mobilePushUnavailableReason) + } DestinationOption( value = "web_push", title = "Web push (browser)", @@ -207,15 +228,22 @@ private fun DestinationOption( subtitle: String, selected: Boolean, onSelect: () -> Unit, + enabled: Boolean = true, ) { Row( modifier = Modifier .fillMaxWidth() - .selectable(selected = selected, onClick = onSelect, role = Role.RadioButton) - .padding(vertical = 8.dp), + .selectable( + selected = selected, + enabled = enabled, + onClick = onSelect, + role = Role.RadioButton, + ) + .padding(vertical = 8.dp) + .alpha(if (enabled) 1f else 0.5f), verticalAlignment = Alignment.CenterVertically, ) { - RadioButton(selected = selected, onClick = null) + RadioButton(selected = selected, onClick = null, enabled = enabled) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.bodyLarge) @@ -333,3 +361,40 @@ private fun ActivationUrlPanel( ) { Text("Done") } Spacer(Modifier.height(24.dp)) } + +/** + * Why the instance cannot offer mobile push, folded away until asked for. + * + * The reason is the server's own words, fetched rather than written here, so + * every client says the same thing. It is a paragraph because the short + * version ("not configured") is what sent people off to read Firebase + * documentation to no purpose: a push credential is paired to an app build, + * not to a server, so this is not a setting anybody can go and fill in. + */ +@Composable +private fun MobilePushUnavailableNote(reason: String?) { + var expanded by remember { mutableStateOf(false) } + Column(Modifier.padding(start = 52.dp, bottom = 8.dp)) { + TextButton( + onClick = { expanded = !expanded }, + contentPadding = PaddingValues(0.dp), + ) { + Text( + "Why is mobile push unavailable?", + style = MaterialTheme.typography.bodySmall, + ) + Icon( + if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + if (expanded) { + Text( + reason ?: "Mobile push is not available on this instance.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ReceivingScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ReceivingScreen.kt index 63c890b..5e983f5 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ReceivingScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/notifications/ReceivingScreen.kt @@ -140,14 +140,20 @@ private fun ReceivingRow( val disabled = channel.destinationState.equals("disabled", ignoreCase = true) val paused = channel.pausedBySender val muted = disabled || paused - // Status suffix on the destination line. Three distinct states: + // Status suffix on the destination line. Four distinct states: // - paused-by-sender: the *sender* turned the channel off; the // recipient can wait for it to come back + // - delivery failures: the SERVER gave up after a day of failures. Its + // own label because falling back to the generic + // wording reads as somebody's choice, when what + // actually happened is that the destination died // - disabled (other): destination dead (e.g. token revoked); the // recipient probably needs to re-redeem // - active: no suffix val statusSuffix = when { paused -> " · paused by sender" + disabled && channel.disabledReason == "delivery_failed" -> + " · stopped: deliveries failing" disabled -> " · disabled" else -> "" } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt index 8608581..54e4481 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt @@ -65,6 +65,7 @@ import systems.lupine.sheaf.data.model.ShareAuditEntry import systems.lupine.sheaf.data.model.ShareGrantRead import systems.lupine.sheaf.data.model.ShareViewRead import systems.lupine.sheaf.ui.components.ErrorBanner +import systems.lupine.sheaf.ui.components.WarningCard import systems.lupine.sheaf.ui.components.SectionHeader import systems.lupine.sheaf.ui.components.datePickerDate import systems.lupine.sheaf.ui.components.datePickerMillis @@ -428,32 +429,6 @@ internal fun parseExpiry(iso: String?): LocalDate? = iso?.let { runCatching { Instant.parse(it).atZone(ZoneId.systemDefault()).toLocalDate() }.getOrNull() } -@Composable -private fun WarningCard(text: String, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = LocalWarningColors.current.container), - ) { - Row( - modifier = Modifier.padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Warning, - contentDescription = null, - tint = LocalWarningColors.current.onContainer, - modifier = Modifier.size(20.dp), - ) - Text( - text, - style = MaterialTheme.typography.bodyMedium, - color = LocalWarningColors.current.onContainer, - ) - } - } -} - // Deliberately coarse server-side: the anonymous surface returns one uniform // 404 for all of these, so naming them precisely would buy nothing. private fun suppressionMessage(reason: String): String = when (reason) { From 0fa113e00b8c1b2e7b1e586e8188199a82b335f6 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:16:04 -0400 Subject: [PATCH 4/4] changelog for the web-parity batch --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d0e3e..a5c785a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ### Added +- **A banner image for your system.** Settings > Profile gains a banner + beside the avatar, the same wide image a member can have, with the same + cropper. Needs a server running 1.6.0 or later. + +- **Custom fields say who can see them.** Each custom field in the member + editor, on a member's profile, and in Settings now carries its privacy + level, so "wait, is that one public?" no longer means leaving the member to + go and check before typing something sensitive. Where a change to a level is + waiting out a System Safety grace period, it says what it will become and + when. Changing a level still happens in Settings. + - **A share view can show everyone set to Public.** Instead of adding members one at a time, turn on "Show everyone set to Public" and the view follows each member's privacy setting from then on. The list you picked by hand is @@ -32,6 +43,24 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ### Fixed +- **Public is no longer offered where the instance cannot publish.** On an + instance with public profiles turned off, choosing Public for a member, + group, custom field, relationship or your system was a dead end that failed + with a permissions error. It is now shown but unavailable, with a line + saying why. Anything already public keeps the option, so it can still be + lowered. + +- **A notification channel that stops working now says so.** When deliveries + to a channel keep failing, the server switches it off after a day. The app + said nothing, and the recipient's copy read "Unsubscribed", which blamed a + person for a broken endpoint. Your channel list now names what stopped and + what to check. Needs a server running 1.6.0 or later. + +- **Mobile push is no longer offered where it cannot work.** Self-hosted + instances without push credentials let you fill in the whole form before + refusing it. The option is now unavailable up front, with the reason + available if you want it. Needs a server running 1.6.0 or later. + - **Re-authentication now reaches the server when editing members, groups and system settings.** The password and code you typed were dropped before the request went out, so a change that needed them could never save.