diff --git a/CHANGELOG.md b/CHANGELOG.md index b5fe50f..e094fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,17 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). screens down and left the search field scrolled off above its own results. Searching also waits for a pause in typing now, instead of asking the server again on every keystroke. +- **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 @@ -56,6 +67,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. 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/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/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 5574a47..3f674c9 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 @@ -267,6 +267,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` @@ -309,6 +312,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, @@ -800,6 +804,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/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/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/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/fields/CustomFieldsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsScreen.kt index 4751c9d..24b6bfd 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 @@ -28,6 +28,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 ─────────────────────────────────────────────────────────────────── @@ -143,6 +144,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) @@ -157,6 +161,7 @@ fun CustomFieldsScreen( EditFieldDialog( field = field, isSaving = state.isSaving, + offersPublic = state.raiseGate.offersPublic(field.privacy), onDismiss = { editingField = null }, onSave = { name, privacy, choices -> viewModel.updateField( @@ -235,16 +240,12 @@ private fun FieldListItem( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text(field.fieldTypeDisplay, style = MaterialTheme.typography.bodySmall) - 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) } + StagedPrivacyNote(field.pendingPrivacy, field.privacyActivatesAt) PendingDeleteBadge(field.pendingDeleteAt) } }, @@ -362,6 +363,7 @@ private fun ChoicesEditor( @Composable private fun AddFieldSheet( isSaving: Boolean, + offersPublic: Boolean, onDismiss: () -> Unit, onSave: (name: String, fieldType: String, privacy: String, choices: List?) -> Unit, ) { @@ -437,6 +439,7 @@ private fun AddFieldSheet( privacyOptions.forEachIndexed { index, option -> SegmentedButton( selected = privacy == option, + enabled = offersPublic || option != "public", onClick = { privacy = option }, shape = SegmentedButtonDefaults.itemShape( index = index, @@ -447,6 +450,13 @@ private fun AddFieldSheet( } } } + if (!offersPublic) { + Text( + systems.lupine.sheaf.ui.sharing.PUBLISHING_OFF_NOTE, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Button( @@ -479,6 +489,7 @@ private fun AddFieldSheet( private fun EditFieldDialog( field: CustomFieldRead, isSaving: Boolean, + offersPublic: Boolean, onDismiss: () -> Unit, onSave: (name: String, privacy: String, choices: List?) -> Unit, ) { @@ -528,6 +539,7 @@ private fun EditFieldDialog( privacyOptions.forEachIndexed { index, option -> SegmentedButton( selected = privacy == option, + enabled = offersPublic || option != "public", onClick = { privacy = option }, shape = SegmentedButtonDefaults.itemShape( index = index, @@ -538,6 +550,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/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..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 @@ -431,7 +432,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 +455,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 +522,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) { @@ -801,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( @@ -1198,10 +1115,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/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/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 aae6aa4..b2ad4c1 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 @@ -709,6 +710,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 { @@ -836,15 +846,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 660e8be..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 @@ -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 @@ -45,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 @@ -81,6 +87,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, @@ -92,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()) } @@ -131,6 +139,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, @@ -151,6 +160,7 @@ class SystemEditViewModel @Inject constructor( saveNeedsStepUp = false, pendingPrivacy = updated.pendingPrivacy, privacyActivatesAt = updated.privacyActivatesAt, + savedPrivacy = updated.privacy, ) } } @@ -221,6 +231,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 = "") } } 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/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) { 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) + } +}