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 @@ -8,6 +8,35 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`).

### Added

- **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
kept but not used while this is on, and comes back if you turn it off.
Members marked never shareable are still never shown. On a view that's
already live, turning it on asks you to re-authenticate and waits out the
grace period like any other way of showing more. Needs a server running
1.6.0 or later.

- **Import from BerryTree (experimental).** Brings across members, custom
statuses (as custom fronts), front history, folders (as groups), tags and
custom fields. Template members are left out unless you tick them. Before
anything is imported, the preview lists every part of your export that
can't be read yet. Needs a server running 1.6.0 or later.

### Changed

- **Raising a member to Public now shows when it takes effect.** With a
System Safety grace period set, the member editor says when a member will
go Public, and picking a lower level cancels it. Editing anything else
about them no longer cancels the wait by accident.

### Fixed

- **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.
"Never shareable" and "Keep fronting private" on a member now save too, and
so does changing a group's privacy.
- **Pin journal entries.** The pin button at the top of an entry keeps it in a
Pinned section above the rest of the journal list. With the Journal entries
toggle in System Safety on, unpinning asks you to re-authenticate and waits
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ class MemberUpdateJsonAdapter(moshi: Moshi) : JsonAdapter<MemberUpdate>() {
clears("birthday", value.birthday)
clears("note", value.note)
clears("emoji", value.emoji)
omitsWhenNull("never_shareable", value.neverShareable)
omitsWhenNull("fronting_private", value.frontingPrivate)
omitsWhenNull("password", value.password)
omitsWhenNull("totp_code", value.totpCode)
}
writer.endObject()
}
Expand All @@ -99,10 +103,13 @@ class GroupUpdateJsonAdapter(moshi: Moshi) : JsonAdapter<GroupUpdate>() {
writer.beginObject()
PatchWriter(writer, anyAdapter).apply {
omitsWhenNull("name", value.name)
omitsWhenNull("privacy", value.privacy)
clears("description", value.description)
clears("color", value.color)
// Clearing this is how a subgroup is promoted back to top level.
clears("parent_id", value.parentId)
omitsWhenNull("password", value.password)
omitsWhenNull("totp_code", value.totpCode)
}
writer.endObject()
}
Expand Down Expand Up @@ -135,6 +142,8 @@ class SystemUpdateJsonAdapter(moshi: Moshi) : JsonAdapter<SystemUpdate>() {
clears("avatar_url", value.avatarUrl)
clears("color", value.color)
clears("note", value.note)
omitsWhenNull("password", value.password)
omitsWhenNull("totp_code", value.totpCode)
}
writer.endObject()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,12 @@ interface SheafApiService {
@Part file: MultipartBody.Part,
): AmpersandPreviewSummary

@Multipart
@POST("/v1/import/berrytree/preview")
suspend fun previewBerryTreeImport(
@Part file: MultipartBody.Part,
): BerryTreePreviewSummary

/**
* Preview an OpenPlural v0.1 import. Accepts a bare `.json` export or an
* `.openplural.zip` bundle (the endpoint sniffs the zip magic). Reuses the
Expand Down
38 changes: 38 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,10 @@ data class MemberRead(
// Set while a request to drop the fronting guard is waiting out the grace
// window. The flag above is still the truth until it passes.
@Json(name = "fronting_private_activates_at") val frontingPrivateActivatesAt: String? = null,
// A raise to public waiting out the grace window. `privacy` above is still
// the truth until privacyActivatesAt passes; lowering cancels it.
@Json(name = "pending_privacy") val pendingPrivacy: String? = null,
@Json(name = "privacy_activates_at") val privacyActivatesAt: String? = null,
) {
val displayNameOrName: String get() = displayName?.takeIf { it.isNotBlank() } ?: name

Expand Down Expand Up @@ -548,6 +552,10 @@ data class MemberCreate(
// Short glyph shown beside the member's name and in place of an
// avatar. Server caps it at 8 code points.
val emoji: String? = null,
// Only consulted when creating them public would publish them, i.e. a
// share view serves everyone set to Public.
val password: String? = null,
@Json(name = "totp_code") val totpCode: String? = null,
)

/** Optional step-up credentials for archiving a member. Only consulted when
Expand Down Expand Up @@ -1185,6 +1193,7 @@ object ImportJobSource {
// unpacks images when present (no separate archive source like Sheaf).
const val OPENPLURAL_FILE = "openplural_file"
const val AMPERSAND_FILE = "ampersand_file"
const val BERRYTREE_FILE = "berrytree_file"
}

// ── Ampersand import ──────────────────────────────────────────────────────────
Expand All @@ -1210,6 +1219,35 @@ data class AmpersandPreviewSummary(
@Json(name = "limit_warnings") val limitWarnings: List<String> = emptyList(),
)

// ── BerryTree import ─────────────────────────────────────────────────────────
//
// Experimental server-side: built from a single sample export, so the preview
// lists every section it cannot read yet instead of dropping them quietly.
// Template members are left out of `members` and counted separately.
@JsonClass(generateAdapter = true)
data class BerryTreePreviewSummary(
@Json(name = "system_name") val systemName: String? = null,
@Json(name = "member_count") val memberCount: Int = 0,
val members: List<BerryTreePreviewItem> = emptyList(),
@Json(name = "template_count") val templateCount: Int = 0,
@Json(name = "custom_front_count") val customFrontCount: Int = 0,
@Json(name = "fronting_type_count") val frontingTypeCount: Int = 0,
@Json(name = "front_history_count") val frontHistoryCount: Int = 0,
@Json(name = "folder_count") val folderCount: Int = 0,
@Json(name = "tag_count") val tagCount: Int = 0,
@Json(name = "custom_field_count") val customFieldCount: Int = 0,
@Json(name = "unsupported_sections") val unsupportedSections: List<BerryTreeSection> = emptyList(),
// What BerryTree's own exporter says it failed to write.
@Json(name = "export_errors") val exportErrors: List<String> = emptyList(),
@Json(name = "limit_warnings") val limitWarnings: List<String> = emptyList(),
)

@JsonClass(generateAdapter = true)
data class BerryTreePreviewItem(val id: String, val name: String)

@JsonClass(generateAdapter = true)
data class BerryTreeSection(val name: String, val count: Int)

// ── Export ──────────────────────────────────────────────────────────────────

/** Async full-backup (with images) request. Step-up: password always, plus
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ data class ShareViewRead(
// when the system is not public, and (member cards) with permalinks off.
@Json(name = "link_preview_effective") val linkPreviewEffective: String = PREVIEW_GENERIC,
@Json(name = "member_link_preview_effective") val memberLinkPreviewEffective: String = PREVIEW_GENERIC,
// Serve every member set to Public, worked out live, instead of the
// hand-picked list. `members` is kept but not consulted while this is on.
@Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean = false,
@Json(name = "created_at") val createdAt: String,
@Json(name = "is_shared") val isShared: Boolean,
// A flag flip that exposes more on an already-shared view is staged. The
Expand All @@ -80,6 +83,7 @@ data class ShareViewRead(
@Json(name = "pending_include_groups") val pendingIncludeGroups: Boolean? = null,
@Json(name = "pending_link_preview_mode") val pendingLinkPreviewMode: String? = null,
@Json(name = "pending_member_link_preview_mode") val pendingMemberLinkPreviewMode: String? = null,
@Json(name = "pending_include_all_public_members") val pendingIncludeAllPublicMembers: Boolean? = null,
@Json(name = "flags_activate_at") val flagsActivateAt: String? = null,
val members: List<ShareViewMemberRead> = emptyList(),
val fields: List<ShareViewFieldRead> = emptyList(),
Expand Down Expand Up @@ -116,6 +120,7 @@ data class ShareViewUpdate(
@Json(name = "include_groups") val includeGroups: Boolean? = null,
@Json(name = "link_preview_mode") val linkPreviewMode: String? = null,
@Json(name = "member_link_preview_mode") val memberLinkPreviewMode: String? = null,
@Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean? = null,
@Json(name = "member_permalinks") val memberPermalinks: Boolean? = null,
val password: String? = null,
@Json(name = "totp_code") val totpCode: String? = null,
Expand Down Expand Up @@ -200,6 +205,9 @@ data class ShareAuditEntry(
@Json(name = "include_fronting") val includeFronting: Boolean,
@Json(name = "include_relationships") val includeRelationships: Boolean,
@Json(name = "include_groups") val includeGroups: Boolean,
// With this on, member_count is curation that is not being used and only
// served_member_count describes the page.
@Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean = false,
@Json(name = "member_permalinks") val memberPermalinks: Boolean,
@Json(name = "relationship_count") val relationshipCount: Int,
@Json(name = "group_count") val groupCount: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,63 @@ private val ampersand = ImportSource(
},
)

private val berryTree = ImportSource(
id = "berrytree",
source = ImportJobSource.BERRYTREE_FILE,
label = "BerryTree",
title = "Import from BerryTree",
subtitle = "Experimental: use a BerryTree .json export",
constants = mapOf(CONFLICT_SKIP to "\"skip\""),
alwaysEmitMemberIds = true,
input = ImportInput.File(
prompt = "Choose your BerryTree export to get started.",
help = "Export your data from BerryTree as JSON, then select the file here. This importer is " +
"experimental: the preview lists anything in your file it can't read yet before " +
"anything is imported.",
),
preview = { api, req ->
val s = api.previewBerryTreeImport(req.file!!)
val x = mutableListOf<String>()
s.unsupportedSections.forEach { x += "${it.name} (${it.count}) - not supported yet, will be skipped" }
if (s.exportErrors.isNotEmpty()) {
x += "BerryTree reported problems writing this export, so it was incomplete before it got here:"
x += s.exportErrors
}
x += s.limitWarnings
ImportPreview(
systemName = s.systemName,
headline = "Experimental. If something you need is listed as not supported, " +
"get in touch and bring your export.",
members = s.members.map { PreviewMember(it.id, it.name) },
memberCount = s.memberCount,
categories = listOf(
ImportCategory(
key = "system_profile",
label = "System profile",
visible = s.systemName != null,
default = s.systemName != null,
),
counted(
"templates", "Template members", s.templateCount, default = false,
note = "These count against your member limit. Only brought across when all members are selected.",
),
counted("custom_fronts", "Custom statuses, as custom fronts", s.customFrontCount),
counted("folders", "Folders, as groups", s.folderCount),
counted("tags", "Tags", s.tagCount),
counted("custom_fields", "Custom fields", s.customFieldCount),
ImportCategory(
key = "front_history",
label = "Front history (${s.frontHistoryCount} entries)",
visible = s.frontHistoryCount > 0,
default = s.frontHistoryCount > 0,
note = if (s.frontingTypeCount > 0) "Fronting types are kept on each entry's status text." else null,
),
),
skipped = x,
)
},
)

/** Every source the Import screen can run, in the order the picker lists them. */
val importSources: List<ImportSource> = listOf(
simplyPlural,
Expand All @@ -466,6 +523,7 @@ val importSources: List<ImportSource> = listOf(
prism,
openPlural,
ampersand,
berryTree,
)

fun importSourceById(id: String?): ImportSource? = importSources.firstOrNull { it.id == id }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ internal fun importSourceLabel(source: String): String = when (source) {
"tupperbox_file" -> "Tupperbox"
"pluralspace_file" -> "PluralSpace"
"prism_file" -> "Prism"
"berrytree_file" -> "BerryTree"
else -> source
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,15 @@ fun MemberDetailScreen(
) { Text(level.replaceFirstChar { it.uppercase() }) }
}
}
val staged = state.member?.takeIf { it.pendingPrivacy != null && form.privacy == it.pendingPrivacy }
staged?.privacyActivatesAt?.let { at ->
Text(
"Staged: goes Public on ${formatRevisionDate(at, LocalDisplayTimeZone.current)}. " +
"Until then they stay ${staged.privacy}. Picking a lower level cancels it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

// Two ceilings that outrank any share view. They live here because
// this is where the member is edited, not on the sharing screen.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,9 @@ class MemberDetailViewModel @Inject constructor(
note = m.note ?: "",
color = m.color ?: "#7F77DD",
birthday = m.birthday ?: "",
privacy = m.privacy,
// A staged raise shows as the level it is heading for,
// so picking anything lower is a change that cancels it.
privacy = m.pendingPrivacy ?: m.privacy,
avatarUrl = m.avatarUrl,
bannerUrl = m.bannerUrl,
neverShareable = m.neverShareable,
Expand Down Expand Up @@ -507,6 +509,8 @@ class MemberDetailViewModel @Inject constructor(
birthday = f.birthday.takeIf { it.isNotBlank() },
privacy = f.privacy,
note = f.note.takeIf { it.isNotBlank() },
password = password?.ifBlank { null },
totpCode = totpCode?.ifBlank { null },
)).also { createdMemberId = it.id }
} else {
val update = MemberUpdate(
Expand All @@ -524,7 +528,10 @@ class MemberDetailViewModel @Inject constructor(
bannerUrl = f.bannerUrl,
color = f.color.takeIf { it.isNotBlank() },
birthday = f.birthday.takeIf { it.isNotBlank() },
privacy = f.privacy,
// Any level other than public counts as a lowering,
// which cancels a staged raise, so only send it when
// it was actually changed.
privacy = f.privacy.takeIf { it != _baselineForm.value.privacy },
// Empty string clears the column server-side; this lets
// a user wipe a note that was previously set.
note = f.note,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ fun ShareViewDetailScreen(
)

SectionHeader("Members in this view")
if (view.includeAllPublicMembers) {
Text(
"This view shows everyone set to Public, so the list below is not being " +
"used. It is kept as it is, and comes back if you turn that off. " +
"Members marked never shareable are still never shown.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
view.members.forEach { row ->
val name = state.allMembers.find { it.id == row.memberId }?.name ?: "Unknown member"
ListItem(
Expand Down Expand Up @@ -431,6 +440,7 @@ internal fun previewNote(

private fun ExposureFlag.liveValue(v: ShareViewRead): Boolean = when (this) {
ExposureFlag.INCLUDE_MEMBERS -> v.includeMembers
ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> v.includeAllPublicMembers
ExposureFlag.INCLUDE_BIO -> v.includeBio
ExposureFlag.INCLUDE_FRONTING -> v.includeFronting
ExposureFlag.FRONTING_SHOW_COUNT -> v.frontingShowCount
Expand All @@ -440,6 +450,7 @@ private fun ExposureFlag.liveValue(v: ShareViewRead): Boolean = when (this) {

private fun ExposureFlag.pendingValue(v: ShareViewRead): Boolean? = when (this) {
ExposureFlag.INCLUDE_MEMBERS -> v.pendingIncludeMembers
ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> v.pendingIncludeAllPublicMembers
ExposureFlag.INCLUDE_BIO -> v.pendingIncludeBio
ExposureFlag.INCLUDE_FRONTING -> v.pendingIncludeFronting
ExposureFlag.FRONTING_SHOW_COUNT -> v.pendingFrontingShowCount
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@ import systems.lupine.sheaf.data.model.ShareViewUpdate
import systems.lupine.sheaf.util.toUserMessage
import javax.inject.Inject

/** The six flags that widen what a view serves, and so carry a pending twin. */
/** The flags that widen what a view serves, and so carry a pending twin. */
enum class ExposureFlag(val label: String, val supporting: String) {
INCLUDE_MEMBERS("Show the roster", "The list of members in this view"),
INCLUDE_ALL_PUBLIC_MEMBERS(
"Show everyone set to Public",
"Follows each member's privacy instead of the list below, so someone you set to Public later appears without editing the view",
),
INCLUDE_BIO("Show bios", "Each member's description"),
INCLUDE_FRONTING("Show who is fronting", "Live front state for members in this view"),
FRONTING_SHOW_COUNT("Count hidden fronters", "Adds \"and N others not shown\""),
Expand Down Expand Up @@ -207,6 +211,7 @@ class ShareViewDetailViewModel @Inject constructor(
val body = when (r) {
is ViewRaise.Flag -> when (r.flag) {
ExposureFlag.INCLUDE_MEMBERS -> ShareViewUpdate(includeMembers = r.value)
ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> ShareViewUpdate(includeAllPublicMembers = r.value)
ExposureFlag.INCLUDE_BIO -> ShareViewUpdate(includeBio = r.value)
ExposureFlag.INCLUDE_FRONTING -> ShareViewUpdate(includeFronting = r.value)
ExposureFlag.FRONTING_SHOW_COUNT -> ShareViewUpdate(frontingShowCount = r.value)
Expand Down
Loading
Loading