Add remove pack protection dialog.

This commit is contained in:
Alex Hart
2026-09-23 16:12:24 -04:00
committed by Michelle Tang
parent 040240c4d2
commit 359807ccf5
9 changed files with 102 additions and 18 deletions
@@ -734,19 +734,11 @@ class ConversationFragment :
)
}
is MediaKeyboardAction.RemoveStickerPackClicked -> {
container.onHostWindowShown()
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.StickerManagement_delete_n_packs_confirmation, 1, 1))
.setMessage(resources.getQuantityString(R.plurals.StickerManagement_delete_n_packs_confirmation_body, 1, 1))
.setPositiveButton(R.string.StickerManagement_menu_remove_pack) { _, _ ->
viewLifecycleOwner.lifecycleScope.launch {
StickerManagementRepository.uninstallStickerPacks(mapOf(StickerPackId(action.packId) to StickerPackKey(action.packKey)))
}
}
.setNegativeButton(android.R.string.cancel, null)
.setOnDismissListener { container.onHostWindowHidden() }
.show()
// The keyboard has already confirmed this with the user.
is MediaKeyboardAction.RemoveStickerPackConfirmed -> {
viewLifecycleOwner.lifecycleScope.launch {
StickerManagementRepository.uninstallStickerPacks(mapOf(StickerPackId(action.packId) to StickerPackKey(action.packKey)))
}
}
// A screen of its own rather than a window over this one, and picking a gif carries on into
@@ -74,7 +74,7 @@ fun MainScreen(
MediaKeyboardAction.GifSearchClicked,
is MediaKeyboardAction.ViewStickerPackClicked,
is MediaKeyboardAction.SendStickerPackClicked,
is MediaKeyboardAction.RemoveStickerPackClicked -> Unit
is MediaKeyboardAction.RemoveStickerPackConfirmed -> Unit
}
}
}
@@ -67,10 +67,10 @@ sealed interface MediaKeyboardAction {
data class SendStickerPackClicked(val packId: String, val packKey: String) : MediaKeyboardAction
/**
* Uninstall a sticker pack, confirming with the user first.
* Uninstall a sticker pack. The keyboard has already confirmed it with the user.
*
* @param packId The pack to remove.
* @param packKey Its key, which identifies the pack alongside [packId].
*/
data class RemoveStickerPackClicked(val packId: String, val packKey: String) : MediaKeyboardAction
data class RemoveStickerPackConfirmed(val packId: String, val packKey: String) : MediaKeyboardAction
}
@@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -42,9 +43,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.SignalPreviewWrapper
import org.signal.core.ui.compose.keyboard.LocalKeyboardSheetController
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.glide.compose.GlideImage
import org.signal.mediakeyboard.R
@@ -77,6 +80,31 @@ fun StickerPageScreen(
onSearchFieldRevealedChange = onSearchFieldRevealedChange
)
}
if (state.confirmRemovePack != null) {
ConfirmRemovePackDialog(onEvent = onEvent)
}
}
@Composable
private fun ConfirmRemovePackDialog(onEvent: (StickerPageScreenEvents) -> Unit) {
val host = LocalKeyboardSheetController.current
// A window of our own in front of the sheet, so the sheet stays put rather than giving way to it.
DisposableEffect(Unit) {
host.onHostWindowShown()
onDispose { host.onHostWindowHidden() }
}
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.MediaKeyboard__remove_sticker_pack_question),
body = stringResource(R.string.MediaKeyboard__this_will_remove_the_sticker_pack),
confirm = stringResource(R.string.MediaKeyboard__remove_pack),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(StickerPageScreenEvents.RemoveStickerPackConfirmed) },
onDeny = { onEvent(StickerPageScreenEvents.RemoveStickerPackCanceled) },
onDismissRequest = { onEvent(StickerPageScreenEvents.RemoveStickerPackCanceled) }
)
}
@Composable
@@ -19,5 +19,7 @@ sealed interface StickerPageScreenEvents {
data class ViewStickerPackClicked(val packId: String, val packKey: String) : StickerPageScreenEvents
data class SendStickerPackClicked(val packId: String, val packKey: String) : StickerPageScreenEvents
data class RemoveStickerPackClicked(val packId: String, val packKey: String) : StickerPageScreenEvents
data object RemoveStickerPackConfirmed : StickerPageScreenEvents
data object RemoveStickerPackCanceled : StickerPageScreenEvents
data object ClearRecentStickersClicked : StickerPageScreenEvents
}
@@ -11,5 +11,16 @@ data class StickerPageState(
val packs: List<KeyboardStickerPack> = emptyList(),
val selectedPackId: String? = null,
val scrollTargetPackId: String? = null,
val allowAnimation: Boolean = true
val allowAnimation: Boolean = true,
val confirmRemovePack: ConfirmRemovePack? = null
)
/**
* The pack the user has asked to remove, held until they confirm or dismiss the prompt.
*
* @param packKey The pack's key, which identifies it alongside [packId] to whoever uninstalls it.
*/
data class ConfirmRemovePack(
val packId: String,
val packKey: String
)
@@ -89,7 +89,20 @@ class StickerPageViewModel(
}
is StickerPageScreenEvents.RemoveStickerPackClicked -> {
onAction(MediaKeyboardAction.RemoveStickerPackClicked(event.packId, event.packKey))
stateEmitter(state.copy(confirmRemovePack = ConfirmRemovePack(event.packId, event.packKey)))
}
is StickerPageScreenEvents.RemoveStickerPackConfirmed -> {
val pack = state.confirmRemovePack
stateEmitter(state.copy(confirmRemovePack = null))
if (pack != null) {
onAction(MediaKeyboardAction.RemoveStickerPackConfirmed(pack.packId, pack.packKey))
}
}
is StickerPageScreenEvents.RemoveStickerPackCanceled -> {
stateEmitter(state.copy(confirmRemovePack = null))
}
is StickerPageScreenEvents.ClearRecentStickersClicked -> {
@@ -63,6 +63,10 @@
<string name="MediaKeyboard__more_options">More options</string>
<!-- Menu option in a sticker pack header's menu that uninstalls the pack. Verb, imperative. -->
<string name="MediaKeyboard__remove_pack">Remove</string>
<!-- Title of the confirmation dialog shown before uninstalling a sticker pack. -->
<string name="MediaKeyboard__remove_sticker_pack_question">Remove sticker pack?</string>
<!-- Body of the confirmation dialog shown before uninstalling a sticker pack. -->
<string name="MediaKeyboard__this_will_remove_the_sticker_pack">This will remove the sticker pack. You can\'t add it again without a link or sticker message.</string>
<!-- Menu option in the recently used header's menu that forgets every recently used sticker. Verb phrase, imperative. -->
<string name="MediaKeyboard__clear_recents">Clear recents</string>
@@ -6,7 +6,9 @@
package org.signal.mediakeyboard.screens.sticker
import assertk.assertThat
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@@ -109,6 +111,38 @@ class StickerPageViewModelTest {
assertThat(actions).isEqualTo(listOf(MediaKeyboardAction.ViewStickerPackClicked("pack-1", "pack-1-key")))
}
@Test
fun `remove pack clicked - prompts instead of removing`() {
val viewModel = createViewModel()
viewModel.onEvent(StickerPageScreenEvents.RemoveStickerPackClicked("pack-1", "pack-1-key"))
testDispatcher.scheduler.advanceUntilIdle()
assertThat(viewModel.state.value.confirmRemovePack).isEqualTo(ConfirmRemovePack("pack-1", "pack-1-key"))
assertThat(actions).isEmpty()
}
@Test
fun `remove pack confirmed - dismisses the prompt and hands the pack's ids to the host`() {
val viewModel = createViewModel()
viewModel.onEvent(StickerPageScreenEvents.RemoveStickerPackClicked("pack-1", "pack-1-key"))
viewModel.onEvent(StickerPageScreenEvents.RemoveStickerPackConfirmed)
testDispatcher.scheduler.advanceUntilIdle()
assertThat(viewModel.state.value.confirmRemovePack).isNull()
assertThat(actions).isEqualTo(listOf(MediaKeyboardAction.RemoveStickerPackConfirmed("pack-1", "pack-1-key")))
}
@Test
fun `remove pack canceled - dismisses the prompt without removing`() {
val viewModel = createViewModel()
viewModel.onEvent(StickerPageScreenEvents.RemoveStickerPackClicked("pack-1", "pack-1-key"))
viewModel.onEvent(StickerPageScreenEvents.RemoveStickerPackCanceled)
testDispatcher.scheduler.advanceUntilIdle()
assertThat(viewModel.state.value.confirmRemovePack).isNull()
assertThat(actions).isEmpty()
}
@Test
fun `search clicked - hands off to the host`() {
val viewModel = createViewModel()