diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt index 8ac5a20..0189225 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt @@ -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 @@ -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 = emptyList(), val fields: List = emptyList(), @@ -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, @@ -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, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt index 51b5e02..20e3a92 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt @@ -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 @@ -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 @@ -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" @@ -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 diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt index 4bca77b..2a31046 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt @@ -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 @@ -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 @@ -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) } /** @@ -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 { @@ -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") } } } @@ -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 -> diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ShareModelsParsingTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ShareModelsParsingTest.kt index c90295b..d2635aa 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ShareModelsParsingTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/ShareModelsParsingTest.kt @@ -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 = """ diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/sharing/LinkPreviewNoteTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/sharing/LinkPreviewNoteTest.kt new file mode 100644 index 0000000..0fbfd8b --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/sharing/LinkPreviewNoteTest.kt @@ -0,0 +1,63 @@ +package systems.lupine.sheaf.ui.sharing + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import systems.lupine.sheaf.data.model.PREVIEW_GENERIC +import systems.lupine.sheaf.data.model.PREVIEW_SYSTEM_DETAILS + +class LinkPreviewNoteTest { + + private fun note( + card: PreviewCard = PreviewCard.PROFILE, + live: String = PREVIEW_GENERIC, + pending: String? = null, + effective: String = PREVIEW_GENERIC, + permalinks: Boolean = true, + ) = previewNote(card, live, pending, effective, permalinks) + + @Test + fun `nothing to explain when the card is off`() { + assertNull(note()) + } + + @Test + fun `nothing to explain when the rich card is really being served`() { + assertNull(note(live = PREVIEW_SYSTEM_DETAILS, effective = PREVIEW_SYSTEM_DETAILS)) + } + + // The case that went unexplained on web: the live mode stays generic while + // a raise is staged, so a note keyed on the live mode never appeared. + @Test + fun `a staged raise is explained even though the live mode is still generic`() { + val n = note(pending = PREVIEW_SYSTEM_DETAILS) + assertTrue(n != null && "grace period" in n, "got: $n") + } + + @Test + fun `on but generic points at share links being the reason`() { + val n = note(live = PREVIEW_SYSTEM_DETAILS) + assertTrue(n != null && "share link" in n, "got: $n") + } + + @Test + fun `a member card with permalinks off says so`() { + assertEquals( + "Member permalinks are off, so there are no member links to preview.", + note(card = PreviewCard.MEMBER, live = PREVIEW_SYSTEM_DETAILS, permalinks = false), + ) + } + + @Test + fun `permalinks only matter for the member card`() { + val n = note(card = PreviewCard.PROFILE, live = PREVIEW_SYSTEM_DETAILS, permalinks = false) + assertTrue(n != null && "share link" in n, "got: $n") + } + + @Test + fun `a staged raise outranks permalinks being off`() { + val n = note(card = PreviewCard.MEMBER, pending = PREVIEW_SYSTEM_DETAILS, permalinks = false) + assertTrue(n != null && "grace period" in n, "got: $n") + } +}