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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class SystemUpdateJsonAdapter(moshi: Moshi) : JsonAdapter<SystemUpdate>() {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnnouncementPublic>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any> = emptyMap(),
@Json(name = "base_all_members") val baseAllMembers: Boolean = false,
@Json(name = "base_include_private") val baseIncludePrivate: Boolean = false,
Expand Down Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
161 changes: 161 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/avatar/BannerPicker.kt
Original file line number Diff line number Diff line change
@@ -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<Uri?>(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()
},
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading