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
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ data class ShareViewRead(
// Instant in both directions and never staged: it exposes nothing new, only
// a stable address for members the roster already shows.
@Json(name = "member_permalinks") val memberPermalinks: Boolean,
// What a chat client shows when someone pastes this view's URL: one of
// PREVIEW_GENERIC or PREVIEW_SYSTEM_DETAILS. Two independent settings,
// because naming the system and naming one member are different
// disclosures. Raising either is an exposure and stages like the flags.
@Json(name = "link_preview_mode") val linkPreviewMode: String = PREVIEW_GENERIC,
@Json(name = "member_link_preview_mode") val memberLinkPreviewMode: String = PREVIEW_GENERIC,
// What the URLs actually unfurl as right now, computed server-side. Reads
// generic while a raise is staged, when only share links reach the view,
// 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,
@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 @@ -67,6 +78,8 @@ data class ShareViewRead(
@Json(name = "pending_include_relationships") val pendingIncludeRelationships: Boolean? = null,
@Json(name = "pending_include_members") val pendingIncludeMembers: Boolean? = null,
@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 = "flags_activate_at") val flagsActivateAt: String? = null,
val members: List<ShareViewMemberRead> = emptyList(),
val fields: List<ShareViewFieldRead> = emptyList(),
Expand All @@ -75,6 +88,9 @@ data class ShareViewRead(
val hasPendingFlags: Boolean get() = flagsActivateAt != null
}

const val PREVIEW_GENERIC = "generic"
const val PREVIEW_SYSTEM_DETAILS = "system_details"

@JsonClass(generateAdapter = true)
data class ShareViewCreate(
val name: String,
Expand All @@ -98,6 +114,8 @@ data class ShareViewUpdate(
@Json(name = "fronting_show_count") val frontingShowCount: Boolean? = null,
@Json(name = "include_relationships") val includeRelationships: Boolean? = null,
@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 = "member_permalinks") val memberPermalinks: Boolean? = null,
val password: String? = null,
@Json(name = "totp_code") val totpCode: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
Expand All @@ -38,9 +39,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import systems.lupine.sheaf.data.model.PREVIEW_GENERIC
import systems.lupine.sheaf.data.model.PREVIEW_SYSTEM_DETAILS
import systems.lupine.sheaf.data.model.ShareViewRead
import systems.lupine.sheaf.ui.components.ErrorBanner
import systems.lupine.sheaf.ui.components.SectionHeader
import systems.lupine.sheaf.ui.theme.LocalWarningColors

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand Down Expand Up @@ -146,6 +150,44 @@ fun ShareViewDetailScreen(
},
)

SectionHeader("Link previews")
PreviewCardRow(
title = "Show the system in link previews",
supporting = "When this view's public profile link is pasted into a chat, the card " +
"shows your system name, avatar and a short piece of your description " +
"instead of a generic card. It reaches everyone in the chat, and the chat " +
"service keeps its own copy. Share links always get the generic card.",
on = view.linkPreviewMode == PREVIEW_SYSTEM_DETAILS,
canTurnOn = state.publicProfilesEnabled,
busy = state.busy,
note = previewNote(
PreviewCard.PROFILE,
view.linkPreviewMode,
view.pendingLinkPreviewMode,
view.linkPreviewEffective,
view.memberPermalinks,
),
onChange = { viewModel.setPreviewCard(PreviewCard.PROFILE, it) },
)
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
PreviewCardRow(
title = "Show members in link previews",
supporting = "The same for a link to one member's page: that member's name and " +
"avatar, and nothing else. A separate choice from the profile card. " +
"Needs the roster and member permalinks on.",
on = view.memberLinkPreviewMode == PREVIEW_SYSTEM_DETAILS,
canTurnOn = state.publicProfilesEnabled && view.includeMembers && view.memberPermalinks,
busy = state.busy,
note = previewNote(
PreviewCard.MEMBER,
view.memberLinkPreviewMode,
view.pendingMemberLinkPreviewMode,
view.memberLinkPreviewEffective,
view.memberPermalinks,
),
onChange = { viewModel.setPreviewCard(PreviewCard.MEMBER, it) },
)

SectionHeader("Members in this view")
view.members.forEach { row ->
val name = state.allMembers.find { it.id == row.memberId }?.name ?: "Unknown member"
Expand Down Expand Up @@ -323,6 +365,70 @@ fun ShareViewDetailScreen(

private enum class PickerKind { MEMBER, FIELD, GROUP }

// Turning a card off is going dark, so it stays available even when whatever
// it would need to turn back on (publishing, permalinks) is missing.
@Composable
private fun PreviewCardRow(
title: String,
supporting: String,
on: Boolean,
canTurnOn: Boolean,
busy: Boolean,
note: String?,
onChange: (Boolean) -> Unit,
) {
ListItem(
headlineContent = { Text(title) },
supportingContent = { Text(supporting) },
trailingContent = {
Switch(checked = on, onCheckedChange = onChange, enabled = !busy && (on || canTurnOn))
},
)
note?.let {
Surface(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
shape = MaterialTheme.shapes.small,
color = LocalWarningColors.current.container,
) {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = LocalWarningColors.current.onContainer,
modifier = Modifier.padding(8.dp),
)
}
}
}

/**
* Why a preview card that is switched on still unfurls as the generic card, or
* null when there is nothing to explain.
*
* A staged raise counts as on. While it waits out the grace window the live
* mode is still generic, which is exactly when this note is needed; guarding on
* the live mode alone would hide it in the one case it was written for.
*/
internal fun previewNote(
card: PreviewCard,
live: String,
pending: String?,
effective: String,
memberPermalinks: Boolean,
): String? {
val on = live == PREVIEW_SYSTEM_DETAILS || pending != null
if (!on || effective != PREVIEW_GENERIC) return null
return when {
pending != null ->
"Still waiting out the grace period, so links preview as the generic card until it activates."
card == PreviewCard.MEMBER && !memberPermalinks ->
"Member permalinks are off, so there are no member links to preview."
else ->
"Links still preview as the generic card. Rich previews only apply to a published " +
"public profile; a share link always previews generically, because its address " +
"is the secret."
}
}

private fun ExposureFlag.liveValue(v: ShareViewRead): Boolean = when (this) {
ExposureFlag.INCLUDE_MEMBERS -> v.includeMembers
ExposureFlag.INCLUDE_BIO -> v.includeBio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import systems.lupine.sheaf.data.api.SheafApiService
import systems.lupine.sheaf.data.model.CustomFieldRead
import systems.lupine.sheaf.data.model.GroupRead
import systems.lupine.sheaf.data.model.MemberRead
import systems.lupine.sheaf.data.model.PREVIEW_GENERIC
import systems.lupine.sheaf.data.model.PREVIEW_SYSTEM_DETAILS
import systems.lupine.sheaf.data.model.SharePreview
import systems.lupine.sheaf.data.model.ShareViewFieldAdd
import systems.lupine.sheaf.data.model.ShareViewGroupAdd
Expand All @@ -35,9 +37,13 @@ enum class ExposureFlag(val label: String, val supporting: String) {
INCLUDE_GROUPS("Show groups", "Public groups, listing only members already shown"),
}

/** The two link preview cards, set independently. */
enum class PreviewCard { PROFILE, MEMBER }

/** An action held back because the server wants credentials for it. */
sealed interface ViewRaise {
data class Flag(val flag: ExposureFlag, val value: Boolean) : ViewRaise
data class Preview(val card: PreviewCard, val on: Boolean) : ViewRaise
data class AddMember(val memberId: String) : ViewRaise
data class AddField(val fieldId: String) : ViewRaise
data class AddGroup(val groupId: String) : ViewRaise
Expand Down Expand Up @@ -140,7 +146,22 @@ class ShareViewDetailViewModel @Inject constructor(
_state.update { it.copy(stepUp = ViewRaise.Flag(flag, value), stepUpError = null) }
return
}
submitFlag(flag, value, null, null)
submitUpdate(ViewRaise.Flag(flag, value), null, null)
}

/**
* A rich card is a raise like any flag: it reaches everyone in a chat
* without them opening anything, and the chat service keeps a copy. So it
* steps up and stages the same way, and turning it off never waits.
*/
fun setPreviewCard(card: PreviewCard, on: Boolean) {
val view = _state.value.view ?: return
val r = ViewRaise.Preview(card, on)
if (on && view.isShared && _state.value.stepUpMeaningful) {
_state.update { it.copy(stepUp = r, stepUpError = null) }
return
}
submitUpdate(r, null, null)
}

/**
Expand Down Expand Up @@ -175,21 +196,31 @@ class ShareViewDetailViewModel @Inject constructor(
fun confirmStepUp(password: String?, totpCode: String?) {
val pending = _state.value.stepUp ?: return
when (pending) {
is ViewRaise.Flag -> submitFlag(pending.flag, pending.value, password, totpCode)
is ViewRaise.Flag, is ViewRaise.Preview -> submitUpdate(pending, password, totpCode)
else -> submit(pending, password, totpCode)
}
}

fun dismissStepUp() { _state.update { it.copy(stepUp = null, stepUpError = null) } }

private fun submitFlag(flag: ExposureFlag, value: Boolean, password: String?, totpCode: String?) {
val body = when (flag) {
ExposureFlag.INCLUDE_MEMBERS -> ShareViewUpdate(includeMembers = value)
ExposureFlag.INCLUDE_BIO -> ShareViewUpdate(includeBio = value)
ExposureFlag.INCLUDE_FRONTING -> ShareViewUpdate(includeFronting = value)
ExposureFlag.FRONTING_SHOW_COUNT -> ShareViewUpdate(frontingShowCount = value)
ExposureFlag.INCLUDE_RELATIONSHIPS -> ShareViewUpdate(includeRelationships = value)
ExposureFlag.INCLUDE_GROUPS -> ShareViewUpdate(includeGroups = value)
private fun submitUpdate(r: ViewRaise, password: String?, totpCode: String?) {
val body = when (r) {
is ViewRaise.Flag -> when (r.flag) {
ExposureFlag.INCLUDE_MEMBERS -> ShareViewUpdate(includeMembers = r.value)
ExposureFlag.INCLUDE_BIO -> ShareViewUpdate(includeBio = r.value)
ExposureFlag.INCLUDE_FRONTING -> ShareViewUpdate(includeFronting = r.value)
ExposureFlag.FRONTING_SHOW_COUNT -> ShareViewUpdate(frontingShowCount = r.value)
ExposureFlag.INCLUDE_RELATIONSHIPS -> ShareViewUpdate(includeRelationships = r.value)
ExposureFlag.INCLUDE_GROUPS -> ShareViewUpdate(includeGroups = r.value)
}
is ViewRaise.Preview -> {
val mode = if (r.on) PREVIEW_SYSTEM_DETAILS else PREVIEW_GENERIC
when (r.card) {
PreviewCard.PROFILE -> ShareViewUpdate(linkPreviewMode = mode)
PreviewCard.MEMBER -> ShareViewUpdate(memberLinkPreviewMode = mode)
}
}
else -> error("Only flag and preview changes go through submitUpdate")
}.copy(password = password?.ifBlank { null }, totpCode = totpCode?.ifBlank { null })

viewModelScope.launch {
Expand All @@ -199,7 +230,7 @@ class ShareViewDetailViewModel @Inject constructor(
_state.update { it.copy(busy = false, stepUp = null, view = updated) }
}
.onFailure { e ->
handleRaiseFailure(e, ViewRaise.Flag(flag, value), "Failed to update this view")
handleRaiseFailure(e, r, "Failed to update this view")
}
}
}
Expand All @@ -220,7 +251,7 @@ class ShareViewDetailViewModel @Inject constructor(
_state.update { it.copy(groupAddResult = result) }
api.getShareView(viewId)
}
is ViewRaise.Flag -> error("Flag changes go through submitFlag")
is ViewRaise.Flag, is ViewRaise.Preview -> error("Flag and preview changes go through submitUpdate")
}
}
.onSuccess { updated ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,52 @@ class ShareModelsParsingTest {
assertNotNull(v)
assertNull(v.flagsActivateAt)
assertTrue(!v.hasPendingFlags)
// An instance from before link previews sends none of these fields.
assertEquals(PREVIEW_GENERIC, v.linkPreviewMode)
assertEquals(PREVIEW_GENERIC, v.memberLinkPreviewEffective)
assertNull(v.pendingLinkPreviewMode)
// A member row defaults to served when the server omits the field.
assertTrue(v.members.isEmpty())
}

@Test
fun `a staged link preview raise keeps the live mode generic`() {
val json = """
{
"id": "view-4",
"name": "Public",
"include_members": true,
"include_bio": false,
"include_fronting": false,
"fronting_show_count": true,
"include_relationships": false,
"include_groups": false,
"member_permalinks": true,
"link_preview_mode": "generic",
"member_link_preview_mode": "system_details",
"link_preview_effective": "generic",
"member_link_preview_effective": "system_details",
"pending_link_preview_mode": "system_details",
"flags_activate_at": "2026-09-26T00:00:00Z",
"created_at": "2026-09-19T00:00:00Z",
"is_shared": true,
"members": [],
"fields": [],
"groups": []
}
""".trimIndent()

val v = moshi.adapter(ShareViewRead::class.java).fromJson(json)
assertNotNull(v)
assertEquals(PREVIEW_GENERIC, v.linkPreviewMode)
assertEquals(PREVIEW_SYSTEM_DETAILS, v.pendingLinkPreviewMode)
assertEquals(PREVIEW_GENERIC, v.linkPreviewEffective)
assertEquals(PREVIEW_SYSTEM_DETAILS, v.memberLinkPreviewMode)
assertEquals(PREVIEW_SYSTEM_DETAILS, v.memberLinkPreviewEffective)
assertNull(v.pendingMemberLinkPreviewMode)
assertTrue(v.hasPendingFlags)
}

@Test
fun `audit keeps the curated count and the served count separate`() {
val json = """
Expand Down
Loading
Loading