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 @@ -44,6 +44,7 @@ class SettingsScreenTest {
onSetMaxMessageLength: (Int) -> Unit = {},
onToggleDefaultPubliclyVisible: (Boolean) -> Unit = {},
onToggleShowAdvancedPostSettings: (Boolean) -> Unit = {},
onTogglePrivateAccount: (Boolean) -> Unit = {},
onRetry: () -> Unit = {},
onDismissError: () -> Unit = {},
) {
Expand All @@ -59,6 +60,7 @@ class SettingsScreenTest {
onSetMaxMessageLength = onSetMaxMessageLength,
onToggleDefaultPubliclyVisible = onToggleDefaultPubliclyVisible,
onToggleShowAdvancedPostSettings = onToggleShowAdvancedPostSettings,
onTogglePrivateAccount = onTogglePrivateAccount,
onDismissError = onDismissError,
)
}
Expand Down Expand Up @@ -298,6 +300,7 @@ class SettingsScreenTest {
onSetMaxMessageLength = { settings = settings.copy(maxMessageLength = it) },
onToggleDefaultPubliclyVisible = {},
onToggleShowAdvancedPostSettings = {},
onTogglePrivateAccount = {},
onDismissError = {},
)
}
Expand All @@ -311,4 +314,89 @@ class SettingsScreenTest {

composeRule.onNodeWithTag(SettingsTestTags.MAX_MESSAGE_LENGTH).assertTextEquals("666")
}

// --- Private account (issue #34) -----------------------------------------

@Test
fun permissions_showsAPublicAccountAsOff() {
setContent(SettingsUiState(settings = UserSettings(isPrivateAccount = false)))

composeRule.onNodeWithTag(SettingsTestTags.PERMISSIONS).assertIsDisplayed()
composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff()
}

@Test
fun permissions_showsAPrivateAccountAsOn() {
setContent(SettingsUiState(settings = UserSettings(isPrivateAccount = true)))

composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOn()
}

@Test
fun permissions_treatsAnAbsentValueAsPublic() {
setContent(SettingsUiState(settings = UserSettings(isPrivateAccount = null)))

composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff()
}

@Test
fun permissions_explainsTheConsequenceOfGoingPrivate() {
setContent(SettingsUiState(settings = UserSettings(isPrivateAccount = false)))

// The wording is the help centre's (/help/people, "Private accounts"): new
// follows become requests, and existing followers keep their access.
composeRule.onNodeWithText(
"New followers must send a follow request that you approve or reject. " +
"Existing followers are not affected \u2014 they remain followers " +
"unless you remove them.",
).assertIsDisplayed()
composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT_NOTE).assertIsDisplayed()
}

@Test
fun togglingPrivateAccount_reportsTheNewValue() {
var toggled: Boolean? = null
setContent(
state = SettingsUiState(settings = UserSettings(isPrivateAccount = false)),
onTogglePrivateAccount = { toggled = it },
)

composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).performClick()

assert(toggled == true)
}

@Test
fun privateAccount_switchFollowsTheStateAfterARefresh() {
// A refresh (or a rolled-back save) re-renders the screen with the value the
// server last reported; the switch must follow it rather than keep the value
// the user tapped.
var settings by mutableStateOf(UserSettings(isPrivateAccount = false))
composeRule.setContent {
InterlinedListTheme {
SettingsScreen(
state = SettingsUiState(settings = settings),
onBack = {},
onRetry = {},
onSelectViewingPreference = {},
onToggleShowPreviews = {},
onSetMessagesPerPage = {},
onSetMaxMessageLength = {},
onToggleDefaultPubliclyVisible = {},
onToggleShowAdvancedPostSettings = {},
// Optimistic apply; the refresh below stands in for the server's answer.
onTogglePrivateAccount = { settings = settings.copy(isPrivateAccount = it) },
onDismissError = {},
)
}
}

composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff().performClick()
composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOn()

// The server says the account is public after all.
settings = settings.copy(isPrivateAccount = false)

composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ object SettingsBounds {

/** The composer's gear options stay hidden unless the account opts in. */
const val DEFAULT_SHOW_ADVANCED_POST_SETTINGS: Boolean = false

/**
* Accounts are public unless the owner says otherwise — the help centre frames
* private as something you "enable" (`/help/account`, "Private account"). An
* absent `isPrivateAccount` must therefore read as public, never as private:
* showing the switch on for an account that is not actually private would
* promise a privacy guarantee the server is not making.
*/
const val DEFAULT_PRIVATE_ACCOUNT: Boolean = false
}

/**
Expand All @@ -62,3 +71,7 @@ val UserSettings.defaultPubliclyVisibleOrDefault: Boolean
/** Whether the composer's advanced options show, falling back to the server default. */
val UserSettings.showAdvancedPostSettingsOrDefault: Boolean
get() = showAdvancedPostSettings ?: SettingsBounds.DEFAULT_SHOW_ADVANCED_POST_SETTINGS

/** Whether the account is private, falling back to public when the API omits it. */
val UserSettings.isPrivateAccountOrDefault: Boolean
get() = isPrivateAccount ?: SettingsBounds.DEFAULT_PRIVATE_ACCOUNT
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import com.interlinedlist.android.feature.profile.domain.SettingsBounds
import com.interlinedlist.android.feature.profile.domain.UserSettings
import com.interlinedlist.android.feature.profile.domain.ViewingPreference
import com.interlinedlist.android.feature.profile.domain.defaultPubliclyVisibleOrDefault
import com.interlinedlist.android.feature.profile.domain.isPrivateAccountOrDefault
import com.interlinedlist.android.feature.profile.domain.maxMessageLengthOrDefault
import com.interlinedlist.android.feature.profile.domain.messagesPerPageOrDefault
import com.interlinedlist.android.feature.profile.domain.showAdvancedPostSettingsOrDefault
Expand All @@ -52,11 +53,14 @@ object SettingsTestTags {
const val PROFILE = "settingsGroupProfile"
const val VIEW_PREFERENCES = "settingsGroupViewPreferences"
const val MESSAGE_SETTINGS = "settingsGroupMessageSettings"
const val PERMISSIONS = "settingsGroupPermissions"
const val SHOW_PREVIEWS = "settingsShowPreviews"
const val MAX_MESSAGE_LENGTH = "settingsMaxMessageLength"
const val MESSAGES_PER_PAGE = "settingsMessagesPerPage"
const val DEFAULT_PUBLICLY_VISIBLE = "settingsDefaultPubliclyVisible"
const val SHOW_ADVANCED_POST_SETTINGS = "settingsShowAdvancedPostSettings"
const val PRIVATE_ACCOUNT = "settingsPrivateAccount"
const val PRIVATE_ACCOUNT_NOTE = "settingsPrivateAccountNote"

/** Tag for one feed-filter option, keyed on its wire value. */
fun viewingPreference(option: ViewingPreference): String =
Expand Down Expand Up @@ -103,6 +107,7 @@ fun SettingsRoute(
onSetMaxMessageLength = viewModel::setMaxMessageLength,
onToggleDefaultPubliclyVisible = viewModel::setDefaultPubliclyVisible,
onToggleShowAdvancedPostSettings = viewModel::setShowAdvancedPostSettings,
onTogglePrivateAccount = viewModel::setPrivateAccount,
onDismissError = viewModel::dismissError,
modifier = modifier,
)
Expand All @@ -121,6 +126,7 @@ fun SettingsScreen(
onSetMaxMessageLength: (Int) -> Unit,
onToggleDefaultPubliclyVisible: (Boolean) -> Unit,
onToggleShowAdvancedPostSettings: (Boolean) -> Unit,
onTogglePrivateAccount: (Boolean) -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -163,6 +169,10 @@ fun SettingsScreen(
onToggleDefaultPubliclyVisible = onToggleDefaultPubliclyVisible,
onToggleShowAdvancedPostSettings = onToggleShowAdvancedPostSettings,
)
PermissionsGroup(
settings = settings,
onTogglePrivateAccount = onTogglePrivateAccount,
)
Spacer(Modifier.height(24.dp))
}

Expand Down Expand Up @@ -307,6 +317,61 @@ private fun MessageSettingsGroup(
}
}

/**
* "Permissions": who may follow the account and therefore see its content.
*
* The web files the private-account switch here — its Settings page lists "Account
* visibility: Make your account private; new followers will need to request to follow
* you and you must approve them" under **Permissions** (`/help/settings`), and both
* `/help/account` and `/help/people` tell users to enable **Private Account** in
* "Settings, then Permissions" — so this group mirrors that placement and name.
*
* The consequence is spelled out next to the switch rather than left to be discovered,
* because flipping it changes who can see the user's content. The wording is the help
* centre's own (`/help/people`, "Private accounts"): "New followers must send a follow
* request that you approve or reject", "Existing followers are not affected; they
* remain followers unless you remove them", "Users who are not approved followers
* cannot see your private messages", and "Switching back to a public account does not
* automatically re-expose previously hidden content to unapproved followers."
*
* Requests themselves are approved or rejected on the Follow requests screen, which
* the Account tab already links to, so the note points there instead of adding a
* second route to the same place.
*/
@Composable
private fun PermissionsGroup(
settings: UserSettings,
onTogglePrivateAccount: (Boolean) -> Unit,
) {
SettingsGroup(
title = "Permissions",
description = "Who can follow you, and who can see what you post.",
modifier = Modifier.testTag(SettingsTestTags.PERMISSIONS),
) {
SettingsSwitchRow(
label = "Private account",
description = "New followers must send a follow request that you approve or " +
"reject. Existing followers are not affected \u2014 they remain followers " +
"unless you remove them.",
checked = settings.isPrivateAccountOrDefault,
onCheckedChange = onTogglePrivateAccount,
tag = SettingsTestTags.PRIVATE_ACCOUNT,
)
Text(
text = "People who aren\u2019t approved followers can\u2019t see your private " +
"messages. Approve or reject pending requests from Account, then Follow " +
"requests. Switching back to public doesn\u2019t automatically re-expose " +
"content to followers you never approved.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.testTag(SettingsTestTags.PRIVATE_ACCOUNT_NOTE),
)
}
}

/** A failed save reported inline above the groups, dismissible by the user. */
@Composable
private fun SaveErrorBanner(message: String, onDismiss: () -> Unit) {
Expand Down Expand Up @@ -341,6 +406,7 @@ private fun SettingsScreenPreview() {
viewingPreference = ViewingPreference.FOLLOWING,
showPreviews = true,
showAdvancedPostSettings = false,
isPrivateAccount = true,
),
),
onBack = {},
Expand All @@ -351,6 +417,7 @@ private fun SettingsScreenPreview() {
onSetMaxMessageLength = {},
onToggleDefaultPubliclyVisible = {},
onToggleShowAdvancedPostSettings = {},
onTogglePrivateAccount = {},
onDismissError = {},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.interlinedlist.android.feature.profile.domain.UserSettings
import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate
import com.interlinedlist.android.feature.profile.domain.ViewingPreference
import com.interlinedlist.android.feature.profile.domain.defaultPubliclyVisibleOrDefault
import com.interlinedlist.android.feature.profile.domain.isPrivateAccountOrDefault
import com.interlinedlist.android.feature.profile.domain.maxMessageLengthOrDefault
import com.interlinedlist.android.feature.profile.domain.messagesPerPageOrDefault
import com.interlinedlist.android.feature.profile.domain.showAdvancedPostSettingsOrDefault
Expand Down Expand Up @@ -117,6 +118,25 @@ class SettingsViewModel @Inject constructor(
)
}

/**
* Makes the account private, or public again.
*
* Turning it on does not disturb existing followers — the help centre is explicit
* that "Existing followers are not affected; they remain followers unless you
* remove them" (`/help/people`, "Private accounts") — it only makes *new* follows
* arrive as requests to approve or reject. A failed save rolls back, so the switch
* never claims a privacy state the server did not accept.
*/
fun setPrivateAccount(private: Boolean) {
val current = _uiState.value.settings ?: return
if (current.isPrivateAccountOrDefault == private) return
save(
optimistic = current.copy(isPrivateAccount = private),
previous = current,
update = UserSettingsUpdate(isPrivateAccount = private),
)
}

/**
* Sets the account's message character limit. Values outside
* [SettingsBounds.MAX_MESSAGE_LENGTH] are refused here, so a bad number never
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class DefaultSettingsRepositoryTest {
defaultPubliclyVisible: Boolean = true,
messagesPerPage: Int = 20,
showAdvancedPostSettings: Boolean = false,
isPrivateAccount: Boolean = false,
) = server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""
Expand All @@ -82,7 +83,7 @@ class DefaultSettingsRepositoryTest {
"showAdvancedPostSettings": $showAdvancedPostSettings,
"latitude": 45.52,
"longitude": -122.68,
"isPrivateAccount": false,
"isPrivateAccount": $isPrivateAccount,
"githubDefaultRepo": "adron/notes",
"notificationTrayLimit": 25
}
Expand Down Expand Up @@ -300,4 +301,49 @@ class DefaultSettingsRepositoryTest {
assertThat(result).isInstanceOf(ApiResult.Failure::class.java)
assertThat(repository.observeSettings().first()?.maxMessageLength).isEqualTo(666)
}

// --- Private account (issue #34) -----------------------------------------

@Test
fun `isPrivateAccount PATCHes alone as a JSON boolean`() = runTest(testDispatcher) {
enqueueUser(isPrivateAccount = true)

val result = repository.update(UserSettingsUpdate(isPrivateAccount = true))

val body = server.takeJsonBody()
assertThat(body.keys).containsExactly("isPrivateAccount")
val sent = body.getValue("isPrivateAccount").jsonPrimitive
assertThat(sent.isString).isFalse()
assertThat(sent.booleanOrNull).isTrue()
assertThat((result as ApiResult.Success).data.isPrivateAccount).isTrue()
assertThat(repository.observeSettings().first()?.isPrivateAccount).isTrue()
}

@Test
fun `going public again PATCHes isPrivateAccount alone`() = runTest(testDispatcher) {
enqueueUser(isPrivateAccount = false)

val result = repository.update(UserSettingsUpdate(isPrivateAccount = false))

val body = server.takeJsonBody()
assertThat(body.keys).containsExactly("isPrivateAccount")
assertThat(body.getValue("isPrivateAccount").jsonPrimitive.booleanOrNull).isFalse()
assertThat((result as ApiResult.Success).data.isPrivateAccount).isFalse()
}

@Test
fun `a rejected private-account save leaves the cached value untouched`() =
runTest(testDispatcher) {
enqueueUser(isPrivateAccount = false)
repository.refresh()
server.takeRequest()

server.enqueue(
MockResponse().setResponseCode(500).setBody("""{ "error": "boom" }"""),
)
val result = repository.update(UserSettingsUpdate(isPrivateAccount = true))

assertThat(result).isInstanceOf(ApiResult.Failure::class.java)
assertThat(repository.observeSettings().first()?.isPrivateAccount).isFalse()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class FakeSettingsRepository : SettingsRepository {
messagesPerPage = update.messagesPerPage ?: current.messagesPerPage,
showAdvancedPostSettings =
update.showAdvancedPostSettings ?: current.showAdvancedPostSettings,
isPrivateAccount = update.isPrivateAccount ?: current.isPrivateAccount,
)
}
}
Loading
Loading