mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
PM-29693: Add introducing archive action card to vault screen (#6390)
This commit is contained in:
+18
@@ -105,6 +105,24 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
|
||||
*/
|
||||
fun clearData(userId: String)
|
||||
|
||||
/**
|
||||
* Retrieves the stored value of whether the introducing archive action card has been dismissed.
|
||||
*/
|
||||
fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean?
|
||||
|
||||
/**
|
||||
* Stores whether the introducing archive action card has been dismissed.
|
||||
*/
|
||||
fun storeIntroducingArchiveActionCardDismissed(
|
||||
userId: String,
|
||||
isDismissed: Boolean?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Emits updates that track [getIntroducingArchiveActionCardDismissed] for the given [userId].
|
||||
*/
|
||||
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
|
||||
|
||||
/**
|
||||
* Retrieves the biometric integrity validity for the given [userId] and
|
||||
* [systemBioIntegrityState].
|
||||
|
||||
+33
@@ -49,6 +49,8 @@ private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMa
|
||||
private const val RESUME_SCREEN = "resumeScreen"
|
||||
private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled"
|
||||
private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime"
|
||||
private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
|
||||
"introducingArchiveActionCardDismissed"
|
||||
|
||||
/**
|
||||
* Primary implementation of [SettingsDiskSource].
|
||||
@@ -87,6 +89,9 @@ class SettingsDiskSourceImpl(
|
||||
private val mutableShowImportLoginsSettingBadgeFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
private val mutableIntroducingArchiveActionCardDismissedFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
@@ -240,8 +245,29 @@ class SettingsDiskSourceImpl(
|
||||
// - show unlock setting badge
|
||||
// - should show add login coach mark
|
||||
// - should show generator coach mark
|
||||
// - should show introducing archive action card dismissed
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
|
||||
getBoolean(
|
||||
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
|
||||
)
|
||||
|
||||
override fun storeIntroducingArchiveActionCardDismissed(
|
||||
userId: String,
|
||||
isDismissed: Boolean?,
|
||||
) {
|
||||
putBoolean(
|
||||
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
|
||||
value = isDismissed,
|
||||
)
|
||||
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed)
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?> =
|
||||
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId)
|
||||
.onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) }
|
||||
|
||||
override fun getAccountBiometricIntegrityValidity(
|
||||
userId: String,
|
||||
systemBioIntegrityState: String,
|
||||
@@ -579,6 +605,13 @@ class SettingsDiskSourceImpl(
|
||||
override fun getAppResumeScreen(userId: String): AppResumeScreenData? =
|
||||
getString(RESUME_SCREEN.appendIdentifier(userId))?.let { json.decodeFromStringOrNull(it) }
|
||||
|
||||
private fun getMutableIntroducingArchiveActionCardDismissedFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Boolean?> =
|
||||
mutableIntroducingArchiveActionCardDismissedFlowMap.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutableLastSyncFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Instant?> =
|
||||
|
||||
+10
@@ -242,6 +242,16 @@ interface SettingsRepository : FlightRecorderManager {
|
||||
*/
|
||||
fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean)
|
||||
|
||||
/**
|
||||
* Gets updates for whether the introducing archive action card is dismissed.
|
||||
*/
|
||||
fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Stores that the introducing archive action card has been dismissed for the active user.
|
||||
*/
|
||||
fun dismissIntroducingArchiveActionCard()
|
||||
|
||||
/**
|
||||
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
|
||||
* user's vault.
|
||||
|
||||
+23
@@ -500,6 +500,29 @@ class SettingsRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean> {
|
||||
val userId = activeUserId ?: return MutableStateFlow(value = false)
|
||||
return settingsDiskSource
|
||||
.getIntroducingArchiveActionCardDismissedFlow(userId = userId)
|
||||
.map { it ?: false }
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = settingsDiskSource
|
||||
.getIntroducingArchiveActionCardDismissed(userId = userId)
|
||||
?: false,
|
||||
)
|
||||
}
|
||||
|
||||
override fun dismissIntroducingArchiveActionCard() {
|
||||
activeUserId?.let {
|
||||
settingsDiskSource.storeIntroducingArchiveActionCardDismissed(
|
||||
userId = it,
|
||||
isDismissed = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult {
|
||||
val userId = activeUserId
|
||||
?: return BiometricsKeyResult.Error(error = NoActiveUserException())
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -18,11 +19,14 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
|
||||
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
|
||||
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
|
||||
import com.bitwarden.ui.platform.components.icon.model.IconData
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.listitem.BitwardenGroupItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
|
||||
@@ -40,6 +44,7 @@ private const val TRASH_TYPES_COUNT: Int = 1
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
fun VaultContent(
|
||||
state: VaultState.ViewState.Content,
|
||||
actionCardState: VaultState.ActionCardState?,
|
||||
vaultHandlers: VaultHandlers,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -72,15 +77,31 @@ fun VaultContent(
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
) {
|
||||
item {
|
||||
item(key = "top_spacer") {
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
}
|
||||
|
||||
actionCardState?.let {
|
||||
item(key = "action_card") {
|
||||
ActionCard(
|
||||
actionCardState = it,
|
||||
vaultHandlers = vaultHandlers,
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.totpItemsCount > 0) {
|
||||
item {
|
||||
item(key = "totp_header") {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.totp),
|
||||
supportingLabel = TOTP_TYPES_COUNT.toString(),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
@@ -88,7 +109,7 @@ fun VaultContent(
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "verification_codes_group") {
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_clock),
|
||||
label = stringResource(id = BitwardenString.verification_codes),
|
||||
@@ -96,6 +117,7 @@ fun VaultContent(
|
||||
onClick = vaultHandlers.verificationCodesClick,
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("VerificationCodesFilter")
|
||||
.standardHorizontalMargin(),
|
||||
@@ -105,11 +127,12 @@ fun VaultContent(
|
||||
}
|
||||
|
||||
if (state.favoriteItems.isNotEmpty()) {
|
||||
item {
|
||||
item(key = "favorites_header") {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.favorites),
|
||||
supportingLabel = state.favoriteItems.count().toString(),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
@@ -117,7 +140,10 @@ fun VaultContent(
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
}
|
||||
|
||||
itemsIndexed(state.favoriteItems) { index, favoriteItem ->
|
||||
itemsIndexed(
|
||||
items = state.favoriteItems,
|
||||
key = { _, favorite -> favorite.id },
|
||||
) { index, favoriteItem ->
|
||||
VaultEntryListItem(
|
||||
startIcon = favoriteItem.startIcon,
|
||||
startIconTestTag = favoriteItem.startIconTestTag,
|
||||
@@ -145,21 +171,23 @@ fun VaultContent(
|
||||
.favoriteItems
|
||||
.toListItemCardStyle(index = index, dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("CipherCell")
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
item {
|
||||
item(key = "favorites_spacer") {
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "types_header") {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.types),
|
||||
supportingLabel = state.itemTypesCount.toString(),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
@@ -167,7 +195,7 @@ fun VaultContent(
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "logins_group") {
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(
|
||||
iconRes = BitwardenDrawable.ic_globe,
|
||||
@@ -178,6 +206,7 @@ fun VaultContent(
|
||||
onClick = vaultHandlers.loginGroupClick,
|
||||
cardStyle = CardStyle.Top(dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("LoginFilter")
|
||||
.standardHorizontalMargin(),
|
||||
@@ -185,7 +214,7 @@ fun VaultContent(
|
||||
}
|
||||
|
||||
if (state.showCardGroup) {
|
||||
item {
|
||||
item(key = "cards_group") {
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(
|
||||
iconRes = BitwardenDrawable.ic_payment_card,
|
||||
@@ -196,6 +225,7 @@ fun VaultContent(
|
||||
onClick = vaultHandlers.cardGroupClick,
|
||||
cardStyle = CardStyle.Middle(dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("CardFilter")
|
||||
.standardHorizontalMargin(),
|
||||
@@ -203,7 +233,7 @@ fun VaultContent(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "identities_group") {
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(
|
||||
iconRes = BitwardenDrawable.ic_id_card,
|
||||
@@ -214,13 +244,14 @@ fun VaultContent(
|
||||
onClick = vaultHandlers.identityGroupClick,
|
||||
cardStyle = CardStyle.Middle(dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("IdentityFilter")
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "notes_group") {
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(
|
||||
iconRes = BitwardenDrawable.ic_note,
|
||||
@@ -231,13 +262,14 @@ fun VaultContent(
|
||||
onClick = vaultHandlers.secureNoteGroupClick,
|
||||
cardStyle = CardStyle.Middle(dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("SecureNoteFilter")
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "ssh_keys_group") {
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(
|
||||
iconRes = BitwardenDrawable.ic_ssh_key,
|
||||
@@ -248,22 +280,24 @@ fun VaultContent(
|
||||
onClick = vaultHandlers.sshKeyGroupClick,
|
||||
cardStyle = CardStyle.Bottom,
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("SshKeyFilter")
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "types_spacer") {
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
}
|
||||
|
||||
if (state.folderItems.isNotEmpty()) {
|
||||
item {
|
||||
item(key = "folders_header") {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.folders),
|
||||
supportingLabel = state.folderItems.count().toString(),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
@@ -271,7 +305,10 @@ fun VaultContent(
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
}
|
||||
|
||||
itemsIndexed(state.folderItems) { index, folder ->
|
||||
itemsIndexed(
|
||||
items = state.folderItems,
|
||||
key = { _, folder -> folder.id ?: "no_folder_group" },
|
||||
) { index, folder ->
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_folder),
|
||||
label = folder.name(),
|
||||
@@ -281,29 +318,34 @@ fun VaultContent(
|
||||
.folderItems
|
||||
.toListItemCardStyle(index = index, dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("FolderFilter")
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
item {
|
||||
item(key = "folders_spacer") {
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.noFolderItems.isNotEmpty()) {
|
||||
item {
|
||||
item(key = "no_folders_header") {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.folder_none),
|
||||
supportingLabel = state.noFolderItems.count().toString(),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
}
|
||||
itemsIndexed(state.noFolderItems) { index, noFolderItem ->
|
||||
itemsIndexed(
|
||||
items = state.noFolderItems,
|
||||
key = { _, noFolderItem -> noFolderItem.id },
|
||||
) { index, noFolderItem ->
|
||||
VaultEntryListItem(
|
||||
startIcon = noFolderItem.startIcon,
|
||||
startIconTestTag = noFolderItem.startIconTestTag,
|
||||
@@ -331,22 +373,24 @@ fun VaultContent(
|
||||
.noFolderItems
|
||||
.toListItemCardStyle(index = index, dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("CipherCell")
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
item {
|
||||
item(key = "no_folders_spacer") {
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.collectionItems.isNotEmpty()) {
|
||||
item {
|
||||
item(key = "collection_header") {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.collections),
|
||||
supportingLabel = state.collectionItems.count().toString(),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
@@ -354,7 +398,10 @@ fun VaultContent(
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
}
|
||||
|
||||
itemsIndexed(state.collectionItems) { index, collection ->
|
||||
itemsIndexed(
|
||||
items = state.collectionItems,
|
||||
key = { _, collection -> collection.id },
|
||||
) { index, collection ->
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_collections),
|
||||
label = collection.name,
|
||||
@@ -364,17 +411,18 @@ fun VaultContent(
|
||||
.collectionItems
|
||||
.toListItemCardStyle(index = index, dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("CollectionFilter")
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
item {
|
||||
item(key = "collections_spacer") {
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "hidden_items_header") {
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = BitwardenString.hidden_items),
|
||||
supportingLabel = if (state.archiveEnabled) {
|
||||
@@ -383,6 +431,7 @@ fun VaultContent(
|
||||
TRASH_TYPES_COUNT.toString()
|
||||
},
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
@@ -391,7 +440,7 @@ fun VaultContent(
|
||||
}
|
||||
|
||||
if (state.archiveEnabled) {
|
||||
item {
|
||||
item(key = "archive_group") {
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_archive),
|
||||
endIcon = state.archiveEndIcon?.let { IconData.Local(iconRes = it) },
|
||||
@@ -401,6 +450,7 @@ fun VaultContent(
|
||||
onClick = vaultHandlers.archiveClick,
|
||||
cardStyle = CardStyle.Top(dividerPadding = 56.dp),
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag(tag = "ArchiveFilter")
|
||||
.standardHorizontalMargin(),
|
||||
@@ -408,7 +458,7 @@ fun VaultContent(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "trash_group") {
|
||||
BitwardenGroupItem(
|
||||
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_trash),
|
||||
label = stringResource(id = BitwardenString.trash),
|
||||
@@ -416,15 +466,45 @@ fun VaultContent(
|
||||
onClick = vaultHandlers.trashClick,
|
||||
cardStyle = if (state.archiveEnabled) CardStyle.Bottom else CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.fillMaxWidth()
|
||||
.testTag("TrashFilter")
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
item(key = "bottom_padding") {
|
||||
Spacer(modifier = Modifier.height(height = 88.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionCard(
|
||||
actionCardState: VaultState.ActionCardState,
|
||||
vaultHandlers: VaultHandlers,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (actionCardState) {
|
||||
VaultState.ActionCardState.IntroducingArchive -> {
|
||||
BitwardenActionCard(
|
||||
cardTitle = stringResource(id = BitwardenString.introducing_archive),
|
||||
cardSubtitle = stringResource(
|
||||
id = BitwardenString.keep_items_you_dont_need_right_now_safe_but_out_sight,
|
||||
),
|
||||
actionText = stringResource(id = BitwardenString.go_to_archive),
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ic_archive),
|
||||
contentDescription = null,
|
||||
tint = BitwardenTheme.colorScheme.icon.secondary,
|
||||
)
|
||||
},
|
||||
onActionClick = vaultHandlers.archiveClick,
|
||||
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +326,7 @@ private fun VaultScreenScaffold(
|
||||
when (val viewState = state.viewState) {
|
||||
is VaultState.ViewState.Content -> VaultContent(
|
||||
state = viewState,
|
||||
actionCardState = state.actionCard,
|
||||
vaultHandlers = vaultHandlers,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
@@ -144,6 +144,9 @@ class VaultViewModel @Inject constructor(
|
||||
restrictItemTypesPolicyOrgIds = emptyList(),
|
||||
cipherDecryptionFailureIds = persistentListOf(),
|
||||
hasShownDecryptionFailureAlert = false,
|
||||
isIntroducingArchiveActionCardDismissed = settingsRepository
|
||||
.getIntroducingArchiveActionCardDismissedFlow()
|
||||
.value,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -217,6 +220,11 @@ class VaultViewModel @Inject constructor(
|
||||
.map { VaultAction.Internal.FlightRecorderDataReceive(data = it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
settingsRepository
|
||||
.getIntroducingArchiveActionCardDismissedFlow()
|
||||
.map { VaultAction.Internal.IntroducingArchiveActionCardDismissedFlowReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
policyManager
|
||||
.getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
@@ -310,6 +318,7 @@ class VaultViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
VaultAction.UpgradeToPremiumClick -> handleUpgradeToPremiumClick()
|
||||
is VaultAction.DismissActionCardClick -> handleDismissActionCardClick(action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +368,14 @@ class VaultViewModel @Inject constructor(
|
||||
sendEvent(VaultEvent.NavigateToUrl(url = url))
|
||||
}
|
||||
|
||||
private fun handleDismissActionCardClick(action: VaultAction.DismissActionCardClick) {
|
||||
when (action.actionCard) {
|
||||
VaultState.ActionCardState.IntroducingArchive -> {
|
||||
settingsRepository.dismissIntroducingArchiveActionCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSelectAddItemType() {
|
||||
// If policy is enable for any organization, exclude the card option
|
||||
val excludedOptions = persistentListOfNotNull(
|
||||
@@ -901,6 +918,9 @@ class VaultViewModel @Inject constructor(
|
||||
|
||||
is VaultAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action)
|
||||
is VaultAction.Internal.UnarchiveCipherReceive -> handleUnarchiveCipherReceive(action)
|
||||
is VaultAction.Internal.IntroducingArchiveActionCardDismissedFlowReceive -> {
|
||||
handleIntroducingArchiveActionCardDismissedFlowReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1001,6 +1021,14 @@ class VaultViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntroducingArchiveActionCardDismissedFlowReceive(
|
||||
action: VaultAction.Internal.IntroducingArchiveActionCardDismissedFlowReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(isIntroducingArchiveActionCardDismissed = action.isDismissed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDecryptionErrorReceive(action: VaultAction.Internal.DecryptionErrorReceive) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
@@ -1412,8 +1440,19 @@ data class VaultState(
|
||||
val cipherDecryptionFailureIds: ImmutableList<String>,
|
||||
val hasShownDecryptionFailureAlert: Boolean,
|
||||
val restrictItemTypesPolicyOrgIds: List<String>,
|
||||
val isIntroducingArchiveActionCardDismissed: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Indicates what action card to display.
|
||||
*/
|
||||
val actionCard: ActionCardState?
|
||||
get() = (viewState as? ViewState.Content)?.let {
|
||||
ActionCardState.IntroducingArchive.takeIf {
|
||||
isPremium && !isIntroducingArchiveActionCardDismissed && isArchiveEnabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The [Color] of the avatar.
|
||||
*/
|
||||
@@ -1725,6 +1764,16 @@ data class VaultState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an action card to be displayed.
|
||||
*/
|
||||
sealed class ActionCardState {
|
||||
/**
|
||||
* Indicates that the archive feature is ready for use.
|
||||
*/
|
||||
data object IntroducingArchive : ActionCardState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a dialog to display.
|
||||
*/
|
||||
@@ -2132,6 +2181,13 @@ sealed class VaultAction {
|
||||
*/
|
||||
data object UpgradeToPremiumClick : VaultAction()
|
||||
|
||||
/**
|
||||
* User clicked the dismiss button on an action card.
|
||||
*/
|
||||
data class DismissActionCardClick(
|
||||
val actionCard: VaultState.ActionCardState,
|
||||
) : VaultAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [VaultViewModel] itself might send.
|
||||
*/
|
||||
@@ -2255,6 +2311,13 @@ sealed class VaultAction {
|
||||
data class UnarchiveCipherReceive(
|
||||
val result: UnarchiveCipherResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the archive action card dismissed state has been updated.
|
||||
*/
|
||||
data class IntroducingArchiveActionCardDismissedFlowReceive(
|
||||
val isDismissed: Boolean,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -51,6 +51,7 @@ data class VaultHandlers(
|
||||
val onEnabledThirdPartyAutofillClick: () -> Unit,
|
||||
val onDismissThirdPartyAutofillDialogClick: () -> Unit,
|
||||
val upgradeToPremiumClick: () -> Unit,
|
||||
val dismissActionCardClick: (VaultState.ActionCardState) -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
@@ -147,6 +148,9 @@ data class VaultHandlers(
|
||||
upgradeToPremiumClick = {
|
||||
viewModel.trySendAction(VaultAction.UpgradeToPremiumClick)
|
||||
},
|
||||
dismissActionCardClick = {
|
||||
viewModel.trySendAction(VaultAction.DismissActionCardClick(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -138,6 +138,10 @@ class SettingsDiskSourceTest {
|
||||
userId = userId,
|
||||
isPullToRefreshEnabled = true,
|
||||
)
|
||||
settingsDiskSource.storeIntroducingArchiveActionCardDismissed(
|
||||
userId = userId,
|
||||
isDismissed = true,
|
||||
)
|
||||
settingsDiskSource.storeInlineAutofillEnabled(
|
||||
userId = userId,
|
||||
isInlineAutofillEnabled = true,
|
||||
@@ -168,6 +172,9 @@ class SettingsDiskSourceTest {
|
||||
assertTrue(settingsDiskSource.getShowUnlockSettingBadge(userId = userId) ?: false)
|
||||
assertTrue(settingsDiskSource.getShowBrowserAutofillSettingBadge(userId = userId) ?: false)
|
||||
assertTrue(settingsDiskSource.getShowAutoFillSettingBadge(userId = userId) ?: false)
|
||||
assertTrue(
|
||||
settingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = userId) ?: false,
|
||||
)
|
||||
|
||||
// These should be cleared
|
||||
assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = userId))
|
||||
@@ -779,6 +786,44 @@ class SettingsDiskSourceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getIntroducingArchiveActionCardDismissed when values are present should pull from SharedPreferences`() {
|
||||
val introducingArchiveBaseKey = "bwPreferencesStorage:introducingArchiveActionCardDismissed"
|
||||
val mockUserId = "mockUserId"
|
||||
val introducingArchiveKey = "${introducingArchiveBaseKey}_$mockUserId"
|
||||
assertNull(settingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = mockUserId))
|
||||
fakeSharedPreferences.edit { putBoolean(introducingArchiveKey, true) }
|
||||
assertEquals(
|
||||
true,
|
||||
settingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = mockUserId),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getIntroducingArchiveActionCardDismissedFlow should react to changes in storeIntroducingArchiveActionCardDismissed`() =
|
||||
runTest {
|
||||
val mockUserId = "mockUserId"
|
||||
settingsDiskSource
|
||||
.getIntroducingArchiveActionCardDismissedFlow(userId = mockUserId)
|
||||
.test {
|
||||
// The initial values of the Flow and the property are in sync
|
||||
assertNull(
|
||||
settingsDiskSource
|
||||
.getIntroducingArchiveActionCardDismissed(userId = mockUserId),
|
||||
)
|
||||
assertNull(awaitItem())
|
||||
|
||||
// Updating the disk source updates shared preferences
|
||||
settingsDiskSource.storeIntroducingArchiveActionCardDismissed(
|
||||
userId = mockUserId,
|
||||
isDismissed = true,
|
||||
)
|
||||
assertEquals(true, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storePullToRefreshEnabled for non-null values should update SharedPreferences`() {
|
||||
val pullToRefreshBaseKey = "bwPreferencesStorage:syncOnRefresh"
|
||||
|
||||
+23
@@ -66,6 +66,7 @@ class FakeSettingsDiskSource(
|
||||
private val storedDisableAutoTotpCopy = mutableMapOf<String, Boolean?>()
|
||||
private val storedDisableAutofillSavePrompt = mutableMapOf<String, Boolean?>()
|
||||
private val storedPullToRefreshEnabled = mutableMapOf<String, Boolean?>()
|
||||
private var storedIntroducingArchiveActionCardDismissed = mutableMapOf<String, Boolean?>()
|
||||
private val storedInlineAutofillEnabled = mutableMapOf<String, Boolean?>()
|
||||
private val storedBlockedAutofillUris = mutableMapOf<String, List<String>?>()
|
||||
private var storedIsIconLoadingDisabled: Boolean? = null
|
||||
@@ -108,6 +109,9 @@ class FakeSettingsDiskSource(
|
||||
private val mutableVaultRegisteredForExportFlow =
|
||||
bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableIntroducingArchiveActionCardDismissedFlow =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
override var appLanguage: AppLanguage?
|
||||
get() = storedAppLanguage
|
||||
set(value) {
|
||||
@@ -325,6 +329,18 @@ class FakeSettingsDiskSource(
|
||||
getMutablePullToRefreshEnabledFlow(userId = userId).tryEmit(isPullToRefreshEnabled)
|
||||
}
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
|
||||
storedIntroducingArchiveActionCardDismissed[userId]
|
||||
|
||||
override fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?> =
|
||||
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId)
|
||||
.onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) }
|
||||
|
||||
override fun storeIntroducingArchiveActionCardDismissed(userId: String, isDismissed: Boolean?) {
|
||||
storedIntroducingArchiveActionCardDismissed[userId] = isDismissed
|
||||
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed)
|
||||
}
|
||||
|
||||
override fun getInlineAutofillEnabled(userId: String): Boolean? =
|
||||
storedInlineAutofillEnabled[userId]
|
||||
|
||||
@@ -532,6 +548,13 @@ class FakeSettingsDiskSource(
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutableIntroducingArchiveActionCardDismissedFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Boolean?> =
|
||||
mutableIntroducingArchiveActionCardDismissedFlow.getOrPut(userId) {
|
||||
bufferedMutableSharedFlow(replay = 1)
|
||||
}
|
||||
|
||||
private fun getMutableShowAutoFillSettingBadgeFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Boolean?> = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) {
|
||||
|
||||
+32
@@ -855,6 +855,38 @@ class SettingsRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissIntroducingArchiveActionCard should properly update SettingsDiskSource`() {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
settingsRepository.dismissIntroducingArchiveActionCard()
|
||||
assertEquals(
|
||||
true,
|
||||
fakeSettingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = USER_ID),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getIntroducingArchiveActionCardDismissedFlow should react to changes in SettingsDiskSource`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
settingsRepository
|
||||
.getIntroducingArchiveActionCardDismissedFlow()
|
||||
.test {
|
||||
assertFalse(awaitItem())
|
||||
fakeSettingsDiskSource.storeIntroducingArchiveActionCardDismissed(
|
||||
userId = USER_ID,
|
||||
isDismissed = true,
|
||||
)
|
||||
assertTrue(awaitItem())
|
||||
fakeSettingsDiskSource.storeIntroducingArchiveActionCardDismissed(
|
||||
userId = USER_ID,
|
||||
isDismissed = false,
|
||||
)
|
||||
assertFalse(awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storePullToRefreshEnabled should properly update SettingsDiskSource`() {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
@@ -1498,6 +1498,58 @@ class VaultScreenTest : BitwardenComposeTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `action cards should be displayed according to state`() {
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Introducing archive")
|
||||
.assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
isPremium = true,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Introducing archive")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IntroducingArchive action card go to archive button click should send ArchiveClick`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
isPremium = true,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Go to archive")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(VaultAction.ArchiveClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IntroducingArchive action card dismiss button click should send DismissActionCardClick`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
isPremium = true,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(label = "Close")
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(
|
||||
VaultAction.DismissActionCardClick(VaultState.ActionCardState.IntroducingArchive),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection data should update according to the state`() {
|
||||
val collectionsHeader = "COLLECTIONS (1)"
|
||||
@@ -2458,6 +2510,7 @@ private val DEFAULT_STATE: VaultState = VaultState(
|
||||
hasShownDecryptionFailureAlert = false,
|
||||
restrictItemTypesPolicyOrgIds = emptyList(),
|
||||
isArchiveEnabled = true,
|
||||
isIntroducingArchiveActionCardDismissed = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content(
|
||||
|
||||
@@ -163,6 +163,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private var mutableFlightRecorderDataFlow =
|
||||
MutableStateFlow(FlightRecorderDataSet(data = emptySet()))
|
||||
private var mutableIntroducingArchiveActionCardDismissedFlow = MutableStateFlow(false)
|
||||
private val settingsRepository: SettingsRepository = mockk {
|
||||
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow
|
||||
every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow
|
||||
@@ -171,6 +172,10 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
every { flightRecorderDataFlow } returns mutableFlightRecorderDataFlow
|
||||
every { dismissFlightRecorderBanner() } just runs
|
||||
every { isAutofillEnabledStateFlow } returns MutableStateFlow(false)
|
||||
every { dismissIntroducingArchiveActionCard() } just runs
|
||||
every {
|
||||
getIntroducingArchiveActionCardDismissedFlow()
|
||||
} returns mutableIntroducingArchiveActionCardDismissedFlow
|
||||
}
|
||||
|
||||
private val vaultRepository: VaultRepository =
|
||||
@@ -234,6 +239,38 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IntroducingArchiveActionCardDismissedFlow updates should update the state accordingly`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableIntroducingArchiveActionCardDismissedFlow.value = true
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(isIntroducingArchiveActionCardDismissed = true),
|
||||
awaitItem(),
|
||||
)
|
||||
mutableIntroducingArchiveActionCardDismissedFlow.value = false
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `DismissActionCardClick with IntroducingArchive should call dismissIntroducingArchiveActionCard`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultAction.DismissActionCardClick(VaultState.ActionCardState.IntroducingArchive),
|
||||
)
|
||||
|
||||
verify(exactly = 1) {
|
||||
settingsRepository.dismissIntroducingArchiveActionCard()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UserState updates with a null value should do nothing`() {
|
||||
val viewModel = createViewModel()
|
||||
@@ -3463,4 +3500,5 @@ private fun createMockVaultState(
|
||||
hasShownDecryptionFailureAlert = false,
|
||||
restrictItemTypesPolicyOrgIds = emptyList(),
|
||||
isArchiveEnabled = true,
|
||||
isIntroducingArchiveActionCardDismissed = false,
|
||||
)
|
||||
|
||||
@@ -1190,6 +1190,9 @@ Do you want to switch to this account?</string>
|
||||
<string name="archiving_items_is_a_premium_feature">Archiving items is a Premium feature. Your current plan does not include access to this feature.</string>
|
||||
<string name="upgrade_to_premium">Upgrade to premium</string>
|
||||
<string name="this_item_is_archived">This item is archived.</string>
|
||||
<string name="introducing_archive">Introducing archive</string>
|
||||
<string name="keep_items_you_dont_need_right_now_safe_but_out_sight">Keep items you don’t need right now safe but out of sight.</string>
|
||||
<string name="go_to_archive">Go to archive</string>
|
||||
<string name="this_item_is_archived_saving_changes_will_restore_it_to_your_vault">This item is archived. Saving changes will restore it to your vault.</string>
|
||||
<string name="your_premium_subscription_ended">Your Premium subscription ended</string>
|
||||
<string name="to_regain_access_to_your_archive_restart_your_premium_subscription">To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault.</string>
|
||||
|
||||
Reference in New Issue
Block a user