Improve UX around max linked devices.

This commit is contained in:
Greyson Parrelli
2026-07-21 10:35:04 -04:00
committed by Michelle Tang
parent 07500f62ab
commit 0b6cd03115
6 changed files with 40 additions and 2 deletions
@@ -72,6 +72,7 @@ class EditDeviceNameFragment : ComposeFragment() {
LinkDeviceSettingsState.OneTimeEvent.ToastNetworkFailed,
is LinkDeviceSettingsState.OneTimeEvent.ToastUnlinked,
LinkDeviceSettingsState.OneTimeEvent.LaunchEmail,
LinkDeviceSettingsState.OneTimeEvent.SnackbarDeviceLimitReached,
LinkDeviceSettingsState.OneTimeEvent.SnackbarLinkCancelled -> Unit
}
}
@@ -26,6 +26,7 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
@@ -74,6 +75,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.DialogState
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.core.ui.R as CoreUiR
@@ -129,6 +131,7 @@ class LinkDeviceFragment : ComposeFragment() {
val state by viewModel.state.collectAsStateWithLifecycle()
val navController: NavController by remember { mutableStateOf(findNavController()) }
val context = LocalContext.current
val atDeviceLimit = state.devices.size >= RemoteConfig.maxLinkedDevices
LaunchedEffect(state.dialogState) {
when (state.dialogState) {
@@ -158,6 +161,9 @@ class LinkDeviceFragment : ComposeFragment() {
LinkDeviceSettingsState.OneTimeEvent.SnackbarLinkCancelled -> {
Snackbar.make(requireView(), getString(R.string.LinkDeviceFragment__linking_cancelled), Snackbar.LENGTH_LONG).show()
}
LinkDeviceSettingsState.OneTimeEvent.SnackbarDeviceLimitReached -> {
Snackbar.make(requireView(), getString(R.string.LinkDeviceFragment__you_can_only_have_d_linked_devices, RemoteConfig.maxLinkedDevices), Snackbar.LENGTH_LONG).show()
}
LinkDeviceSettingsState.OneTimeEvent.ToastNetworkFailed -> {
Toast.makeText(requireContext(), getString(R.string.DeviceListActivity_network_failed), Toast.LENGTH_LONG).show()
}
@@ -195,10 +201,15 @@ class LinkDeviceFragment : ComposeFragment() {
DeviceListScreen(
state = state,
modifier = Modifier.padding(contentPadding),
atDeviceLimit = atDeviceLimit,
onLearnMoreClicked = { navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceLearnMoreBottomSheet) },
onLinkNewDeviceClicked = {
viewModel.stopExistingPolling()
navController.navigateToQrScannerIfAuthed()
if (atDeviceLimit) {
viewModel.onDeviceLimitReached()
} else {
viewModel.stopExistingPolling()
navController.navigateToQrScannerIfAuthed()
}
},
onDeviceSelectedForRemoval = { device -> viewModel.setDeviceToRemove(device) },
onDeviceRemovalConfirmed = { device -> viewModel.removeDevice(device) },
@@ -272,6 +283,7 @@ class LinkDeviceFragment : ComposeFragment() {
fun DeviceListScreen(
state: LinkDeviceSettingsState,
modifier: Modifier = Modifier,
atDeviceLimit: Boolean = false,
onLearnMoreClicked: () -> Unit = {},
onLinkNewDeviceClicked: () -> Unit = {},
onDeviceSelectedForRemoval: (Device?) -> Unit = {},
@@ -411,8 +423,17 @@ fun DeviceListScreen(
Spacer(modifier = Modifier.size(20.dp))
val linkButtonColors = ButtonDefaults.filledTonalButtonColors()
Buttons.LargeTonal(
onClick = onLinkNewDeviceClicked,
colors = if (atDeviceLimit) {
linkButtonColors.copy(
containerColor = linkButtonColors.disabledContainerColor,
contentColor = linkButtonColors.disabledContentColor
)
} else {
linkButtonColors
},
modifier = Modifier
.defaultMinSize(300.dp)
.padding(bottom = 8.dp)
@@ -41,6 +41,7 @@ data class LinkDeviceSettingsState(
data class ToastUnlinked(val name: String) : OneTimeEvent
data class ToastLinked(val name: String) : OneTimeEvent
data object SnackbarLinkCancelled : OneTimeEvent
data object SnackbarDeviceLimitReached : OneTimeEvent
data object SnackbarNameChangeSuccess : OneTimeEvent
data object SnackbarNameChangeFailure : OneTimeEvent
data object ShowFinishedSheet : OneTimeEvent
@@ -152,6 +152,10 @@ class LinkDeviceViewModel : ViewModel() {
pollJob?.cancel()
}
fun onDeviceLimitReached() {
_state.update { it.copy(oneTimeEvent = OneTimeEvent.SnackbarDeviceLimitReached) }
}
fun markQrEducationSheetSeen() {
SignalStore.uiHints.markHasSeenLinkDeviceQrEducationSheet()
_state.update {
@@ -605,6 +605,15 @@ object RemoteConfig {
hotSwappable = true
)
/** The maximum number of linked devices a user can have. */
@JvmStatic
@get:JvmName("maxLinkedDevices")
val maxLinkedDevices: Int by remoteInt(
key = "global.maxLinkedDevices",
defaultValue = 5,
hotSwappable = true
)
/** The maximum number of grapheme */
@JvmStatic
val maxGroupNameGraphemeLength: Int by remoteValue(
+2
View File
@@ -1122,6 +1122,8 @@
<string name="LinkDeviceFragment__edit_name">Edit name</string>
<!-- Toast shown when the process of linking a device has been cancelled -->
<string name="LinkDeviceFragment__linking_cancelled">Linking cancelled</string>
<!-- Snackbar shown when the user tries to link a new device but has already reached the maximum number of linked devices. Placeholder is the maximum count. -->
<string name="LinkDeviceFragment__you_can_only_have_d_linked_devices">You can only have %1$d linked devices.</string>
<!-- Message shown in progress dialog telling users to avoid closing the app while messages are being synced -->
<string name="LinkDeviceFragment__do_not_close">Do not close app</string>
<!-- Dialog title shown when a device is unlinked -->