From 5b2e9514812c364e8b40de8ac41dde8d8a620c53 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 29 Sep 2026 12:59:29 -0300 Subject: [PATCH] Add sticker confirmation overlay. --- .../securesms/conversation/v2/ChatScreen.kt | 19 +- .../v2/ChatStickerConfirmation.kt | 243 ++++++++++++++++++ .../v2/ChatStickerConfirmationController.kt | 60 +++++ .../conversation/v2/ConversationFragment.kt | 107 +++++++- app/src/main/res/values/strings.xml | 8 + .../compose/keyboard/KeyboardSheetScaffold.kt | 67 +++-- .../ui/compose/keyboard/KeyboardSheetScope.kt | 24 +- .../mediakeyboard/demo/main/MainScreen.kt | 1 + .../org/signal/mediakeyboard/MediaKeyboard.kt | 13 +- .../mediakeyboard/MediaKeyboardAction.kt | 9 +- .../data/StickerKeyboardRepository.kt | 2 +- .../screens/emoji/EmojiPageViewModel.kt | 20 +- .../screens/gif/GifPageViewModel.kt | 20 +- .../screens/sticker/StickerPageScreen.kt | 2 +- .../sticker/StickerPageScreenEvents.kt | 1 + .../screens/sticker/StickerPageViewModel.kt | 30 ++- .../screens/emoji/EmojiPageViewModelTest.kt | 6 +- .../screens/gif/GifPageViewModelTest.kt | 6 +- .../sticker/StickerPageViewModelTest.kt | 20 +- 19 files changed, 592 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatStickerConfirmation.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatStickerConfirmationController.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt index 27e4c38bdf..e4a25bc439 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.view.View +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets @@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,6 +30,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.fragment.compose.AndroidFragment import org.signal.core.ui.compose.keyboard.KeyboardSheetAction @@ -68,6 +71,7 @@ private const val BUBBLE_HEIGHT_FRACTION = 0.55f * @param conversationView The conversation's own view hierarchy, and the only interop view here. * A second one sharing pointer input would leave this one without its ACTION_HOVER_EXIT, which is * why the long press overlay is Compose. See stylus-hover-interop.md. + * @param stickerConfirmation Asks the user to confirm a sticker picked from the media keyboard */ @Composable fun ChatScreen( @@ -82,6 +86,7 @@ fun ChatScreen( isBubble: Boolean, conversationView: View, overlayController: ChatReactionOverlayController, + stickerConfirmation: ChatStickerConfirmationController, modifier: Modifier = Modifier ) { val minimumHeight = dimensionResource(R.dimen.default_custom_keyboard_size) @@ -91,6 +96,11 @@ fun ChatScreen( val minimumVisibleContent = with(LocalDensity.current) { minimumVisibleContentPx.toDp() } + val stickerBlurRadius by animateDpAsState( + targetValue = if (stickerConfirmation.isShowing) StickerConfirmationBlurRadius else 0.dp, + label = "stickerBlurRadius" + ) + val keyboardHeight = remember(minimumHeight, topMargin, minimumVisibleContent, isBubble) { KeyboardSheetHeight( minimum = minimumHeight, @@ -134,7 +144,14 @@ fun ChatScreen( keyboard( key = ChatKeyboards.Media, containerColor = mediaKeyboardColor, - expandable = true + expandable = true, + overlay = { + ChatStickerConfirmation( + controller = stickerConfirmation, + modifier = Modifier.fillMaxSize() + ) + }, + blurRadius = { stickerBlurRadius } ) { MediaKeyboard( repository = mediaKeyboardRepository, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatStickerConfirmation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatStickerConfirmation.kt new file mode 100644 index 0000000000..5458bdc9d7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatStickerConfirmation.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +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.SignalIcons +import org.signal.core.ui.compose.SignalPreviewWrapper +import org.signal.core.ui.compose.navigationBarsCompat +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.glide.compose.GlideImage +import org.signal.mediakeyboard.data.KeyboardSticker +import org.thoughtcrime.securesms.R + +private const val PANEL_ALPHA = 0.6f + +/** Used below API 31, where the keyboard behind the panel can't be blurred. */ +private const val UNBLURRED_PANEL_ALPHA = 0.9f + +/** How much to blur the keyboard behind the panel. */ +internal val StickerConfirmationBlurRadius = 20.dp + +private val PanelShape = RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp) +private val ButtonSize = 40.dp +private val StickerSize = 200.dp + +/** + * Asks the user to confirm a sticker picked from the media keyboard, over the whole keyboard sheet. + * The sheet blurs the keyboard behind it by [StickerConfirmationBlurRadius]. + */ +@Composable +fun ChatStickerConfirmation( + controller: ChatStickerConfirmationController, + modifier: Modifier = Modifier +) { + val confirmation = controller.confirmation + var lastConfirmation by remember { mutableStateOf(null) } + if (confirmation != null) { + lastConfirmation = confirmation + } + + BackHandler(enabled = confirmation != null) { + controller.dismiss() + } + + AnimatedVisibility( + visible = confirmation != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier + ) { + lastConfirmation?.let { + StickerConfirmationPanel( + confirmation = it, + sendColor = controller.sendColor, + onBack = controller::dismiss, + onSend = controller::send, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Composable +private fun StickerConfirmationPanel( + confirmation: StickerConfirmation, + sendColor: Color, + onBack: () -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(PanelShape) + .background(SignalTheme.colors.colorSurface3.copy(alpha = if (Build.VERSION.SDK_INT >= 31) PANEL_ALPHA else UNBLURRED_PANEL_ALPHA)) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { it.consume() } + } + } + } + .windowInsetsPadding(WindowInsets.navigationBarsCompat.only(WindowInsetsSides.Bottom)) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 16.dp, end = 16.dp) + ) { + IconButton( + onClick = onBack, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier + .size(ButtonSize) + .align(Alignment.CenterStart) + ) { + Icon( + imageVector = SignalIcons.ArrowStart.imageVector, + contentDescription = stringResource(R.string.ChatStickerConfirmation__go_back) + ) + } + + if (confirmation.replyTo != null) { + ReplyHeader( + replyTo = confirmation.replyTo, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = ButtonSize + 8.dp) + ) + } + + IconButton( + onClick = onSend, + colors = IconButtonDefaults.iconButtonColors( + containerColor = sendColor, + contentColor = colorResource(R.color.conversation_send_button_tint) + ), + modifier = Modifier + .size(ButtonSize) + .align(Alignment.CenterEnd) + ) { + Icon( + imageVector = SignalIcons.SendFill.imageVector, + contentDescription = stringResource(R.string.conversation_activity__send) + ) + } + } + + GlideImage( + model = confirmation.sticker.image, + enableApngAnimation = confirmation.sticker.isAnimated, + modifier = Modifier + .size(StickerSize) + .align(Alignment.Center) + ) + } +} + +@Composable +private fun ReplyHeader( + replyTo: String, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_reply_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + + Text( + text = stringResource(R.string.ChatStickerConfirmation__reply_to_s, replyTo), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun StickerConfirmationPanelPreview() { + StickerConfirmationPanel( + confirmation = StickerConfirmation(sticker = previewSticker, replyTo = null), + sendColor = Color(0xFF315FF4), + onBack = {}, + onSend = {}, + modifier = Modifier.size(width = 412.dp, height = 409.dp) + ) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun StickerConfirmationPanelReplyPreview() { + StickerConfirmationPanel( + confirmation = StickerConfirmation(sticker = previewSticker, replyTo = "Maya Johnson"), + sendColor = Color(0xFF315FF4), + onBack = {}, + onSend = {}, + modifier = Modifier.size(width = 412.dp, height = 409.dp) + ) +} + +private val previewSticker = KeyboardSticker( + packId = "pack", + packKey = "key", + stickerId = 1, + emoji = null, + image = Unit +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatStickerConfirmationController.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatStickerConfirmationController.kt new file mode 100644 index 0000000000..a0a1efe0ec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatStickerConfirmationController.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import org.signal.mediakeyboard.data.KeyboardSticker + +/** + * Holds the sticker waiting on the user to confirm it. + * + * @param onShowingChanged Called as a confirmation appears or goes away, for the host to get the + * input panel out of the way while one shows. + */ +class ChatStickerConfirmationController( + private val onSend: (KeyboardSticker) -> Unit, + private val onShowingChanged: (Boolean) -> Unit +) { + + var confirmation: StickerConfirmation? by mutableStateOf(null) + private set + + var sendColor: Color by mutableStateOf(Color.Unspecified) + + val isShowing: Boolean + get() = confirmation != null + + /** @param replyTo Who the sticker will reply to, or null when it is not a reply. */ + fun show(sticker: KeyboardSticker, replyTo: String?) { + update(StickerConfirmation(sticker, replyTo)) + } + + fun dismiss() { + update(null) + } + + fun send() { + val sticker = confirmation?.sticker ?: return + update(null) + onSend(sticker) + } + + private fun update(value: StickerConfirmation?) { + val wasShowing = isShowing + confirmation = value + if (wasShowing != isShowing) { + onShowingChanged(isShowing) + } + } +} + +data class StickerConfirmation( + val sticker: KeyboardSticker, + val replyTo: String? +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index a3f81a1e5f..6da1b6f337 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.Manifest +import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.ActivityOptions import android.app.PendingIntent @@ -67,6 +68,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.animation.doOnEnd import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat @@ -76,11 +78,13 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.doOnPreDraw import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -154,6 +158,7 @@ import org.signal.donations.InAppPaymentType import org.signal.emoji.EmojiEventListener import org.signal.mediakeyboard.MediaKeyboardAction import org.signal.mediakeyboard.MediaKeyboardTab +import org.signal.mediakeyboard.data.KeyboardSticker import org.signal.ringrtc.CallLinkRootKey import org.thoughtcrime.securesms.BlockUnblockDialog import org.thoughtcrime.securesms.MainActivity @@ -417,6 +422,7 @@ import java.util.Optional import java.util.concurrent.ExecutionException import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds +import androidx.compose.ui.graphics.Color as ComposeColor import org.signal.core.ui.R as CoreUiR /** @@ -451,6 +457,7 @@ class ConversationFragment : private const val MINIMUM_VISIBLE_MESSAGES_DP: Int = 96 private const val SCROLL_HEADER_ANIMATION_DURATION: Long = 100L + private const val STICKER_CONFIRMATION_INPUT_PANEL_DURATION_MS: Long = 250L private const val SCROLL_HEADER_CLOSE_DELAY: Long = SCROLL_HEADER_ANIMATION_DURATION * 4 private const val IS_SCROLLED_TO_BOTTOM_THRESHOLD: Int = 2 @@ -676,6 +683,85 @@ class ConversationFragment : SignalMediaKeyboardRepository(requireContext(), recentEmojis) } + private val stickerConfirmation: ChatStickerConfirmationController by lazy(LazyThreadSafetyMode.NONE) { + ChatStickerConfirmationController( + onSend = { sticker -> + mediaKeyboardRepository.stickers.onStickerUsed(sticker) + sendKeyboardSticker(sticker) + }, + onShowingChanged = ::onStickerConfirmationShowingChanged + ) + } + + private var inputPanelCollapseAnimator: ValueAnimator? = null + + /** + * Collapses the input panel down behind the keyboard sheet while a sticker waits to be confirmed, + * so the conversation reclaims its space. + */ + private fun onStickerConfirmationShowingChanged(showing: Boolean) { + inputPanel.importantForAccessibility = if (showing) View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO + + val targetHeight = if (showing) { + 0 + } else { + inputPanel.measure( + View.MeasureSpec.makeMeasureSpec(inputPanel.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + inputPanel.measuredHeight + } + + inputPanelCollapseAnimator?.cancel() + inputPanelCollapseAnimator = ValueAnimator.ofInt(inputPanel.height, targetHeight).apply { + duration = STICKER_CONFIRMATION_INPUT_PANEL_DURATION_MS + interpolator = FastOutSlowInInterpolator() + addUpdateListener { animator -> + inputPanel.updateLayoutParams { height = animator.animatedValue as Int } + } + doOnEnd { + if (!showing) { + inputPanel.updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT } + } + } + start() + } + + inputPanel.animate() + .alpha(if (showing) 0f else 1f) + .setDuration(STICKER_CONFIRMATION_INPUT_PANEL_DURATION_MS) + .setInterpolator(FastOutSlowInInterpolator()) + .start() + } + + private fun sendKeyboardSticker(sticker: KeyboardSticker) { + viewLifecycleOwner.lifecycleScope.launch { + val record = withContext(Dispatchers.Default) { + SignalDatabase.stickers.getSticker(sticker.packId, sticker.stickerId.toInt(), false) + } + + if (record != null) { + sendSticker(stickerRecord = record, clearCompose = false) + } + } + } + + /** Who a sticker sent now would reply to, matching whether [sendSticker] will attach the quote. */ + private suspend fun stickerReplyToName(): String? { + if (!SignalStore.labs.stickerReplies) { + return null + } + + val authorId = inputPanel.quote.orNull()?.author ?: return null + val author = withContext(Dispatchers.Default) { Recipient.resolved(authorId) } + + return if (author.isSelf) { + getString(R.string.ChatStickerConfirmation__reply_to_yourself) + } else { + getString(R.string.ChatStickerConfirmation__reply_to_s, author.getDisplayName(requireContext())) + } + } + private fun onMediaKeyboardAction(action: MediaKeyboardAction) { when (action) { is MediaKeyboardAction.EmojiSelected -> inputPanel.onEmojiSelected(action.emoji) @@ -684,16 +770,12 @@ class ConversationFragment : is MediaKeyboardAction.StickerSelected -> { viewLifecycleOwner.lifecycleScope.launch { - val record = withContext(Dispatchers.Default) { - SignalDatabase.stickers.getSticker(action.sticker.packId, action.sticker.stickerId.toInt(), false) - } - - if (record != null) { - sendSticker(stickerRecord = record, clearCompose = false) - } + stickerConfirmation.show(action.sticker, stickerReplyToName()) } } + is MediaKeyboardAction.StickerSendClicked -> sendKeyboardSticker(action.sticker) + is MediaKeyboardAction.GifSelected -> { val image = mediaKeyboardRepository.gifs.getGiphyImage(action.gif.id) if (image != null) { @@ -877,7 +959,8 @@ class ConversationFragment : scrims = chatScrims, isBubble = args.conversationScreenType == ConversationScreenType.BUBBLE, conversationView = conversationContent, - overlayController = reactionOverlay + overlayController = reactionOverlay, + stickerConfirmation = stickerConfirmation ) } } @@ -945,10 +1028,13 @@ class ConversationFragment : is KeyboardSheetAction.KeyboardShown -> { if (action.key == ChatKeyboards.Media) { onShown() + } else { + stickerConfirmation.dismiss() } } KeyboardSheetAction.KeyboardHidden -> { + stickerConfirmation.dismiss() setNavBarBackgroundColor(viewModel.wallpaperSnapshot != null || viewModel.recipientSnapshot?.isReleaseNotes == true) onHidden() container.onInputHidden() @@ -1186,6 +1272,10 @@ class ConversationFragment : dismissGifProgressDialog() + stickerConfirmation.dismiss() + inputPanelCollapseAnimator?.cancel() + inputPanelCollapseAnimator = null + if (!requireActivity().isChangingConfigurations) { (requireActivity().supportFragmentManager.findFragmentByTag(MESSAGE_DETAILS_TAG) as? DialogFragment)?.dismissAllowingStateLoss() DeletedMessageTombstoneCache.clearThread(args.threadId) @@ -2265,6 +2355,7 @@ class ConversationFragment : private fun presentChatColors(chatColors: ChatColors) { recyclerViewColorizer.setChatColors(chatColors) + stickerConfirmation.sendColor = ComposeColor(chatColors.asSingleColor()) binding.scrollToMention.setUnreadCountBackgroundTint(chatColors.asSingleColor()) binding.scrollToBottom.setUnreadCountBackgroundTint(chatColors.asSingleColor()) binding.conversationInputPanel.buttonToggle.background.apply { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cafa953eb5..ab0fbd69aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -511,6 +511,14 @@ %d unread messages + + + Reply to %1$s + + Reply to yourself + + Go back + Contacts app not found. diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt index dc7bfdc16b..2cc0218a96 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt @@ -51,8 +51,10 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.input.pointer.pointerInput @@ -488,35 +490,58 @@ fun KeyboardSheetScaffold( .graphicsLayer { translationY = backProgress.value * visibleSheetHeightPx() } .background(containerColor) ) { - Column( - modifier = Modifier - .layout { measurable, constraints -> - // Only the part of the sheet the window shows is worth laying content out in, and - // never less than a keyboard: below that the sheet is on its way out, not resizing. - val visible = visibleSheetHeightPx().coerceIn(heightPx, constraints.maxHeight) - val placeable = measurable.measure(constraints.copy(minHeight = visible, maxHeight = visible)) - layout(constraints.maxWidth, constraints.maxHeight) { - placeable.place(0, 0) + val overlay = registry.overlayFor(visibleKey) + val blurRadius = registry.blurRadiusFor(visibleKey) + + Box( + modifier = Modifier.layout { measurable, constraints -> + // Only the part of the sheet the window shows is worth laying content out in, and + // never less than a keyboard: below that the sheet is on its way out, not resizing. + val visible = visibleSheetHeightPx().coerceIn(heightPx, constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = visible, maxHeight = visible)) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place(0, 0) + } + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + val radiusPx = blurRadius().toPx() + if (radiusPx > 0f) { + renderEffect = BlurEffect(radiusPx, radiusPx, TileMode.Clamp) + clip = true + } else { + renderEffect = null + clip = false + } + } + .windowInsetsPadding(WindowInsets.navigationBarsCompat) + ) { + if (expandable) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + BottomSheets.Handle() } } - .windowInsetsPadding(WindowInsets.navigationBarsCompat) - ) { - if (expandable) { + Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) { - BottomSheets.Handle() + CompositionLocalProvider(LocalKeyboardSheetController provides controller) { + registry.contentFor(visibleKey)?.invoke() + } } } - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) { + if (overlay != null) { CompositionLocalProvider(LocalKeyboardSheetController provides controller) { - registry.contentFor(visibleKey)?.invoke() + overlay() } } } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt index 2ba71db681..6f67791930 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt @@ -7,6 +7,8 @@ package org.signal.core.ui.compose.keyboard import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp /** Declares which keyboards a [KeyboardSheetScaffold] offers. */ interface KeyboardSheetScope { @@ -18,6 +20,12 @@ interface KeyboardSheetScope { * [content] paints its edges with. Unspecified falls back to the scaffold's surface. * @param expandable True to let this keyboard grow past keyboard height to fill the window. Gives * the sheet a drag handle, lets it be swiped, and dims what is behind it as it grows. + * @param overlay Drawn over the whole sheet, drag handle and navigation bar included. It fills + * the sheet whether or not it draws anything, so it is left to the overlay to take touches away + * from the keyboard. + * @param blurRadius How much to blur the sheet beneath [overlay]. Read as the sheet draws, so it + * can animate without recomposing anything. Below API 31 the sheet is never blurred, so + * [overlay] needs a background opaque enough to stand on its own there. * @param content The keyboard itself. */ fun keyboard( @@ -25,6 +33,8 @@ interface KeyboardSheetScope { enabled: Boolean = true, containerColor: Color = Color.Unspecified, expandable: Boolean = false, + overlay: (@Composable () -> Unit)? = null, + blurRadius: () -> Dp = { 0.dp }, content: @Composable () -> Unit ) } @@ -37,9 +47,11 @@ internal class KeyboardSheetRegistry : KeyboardSheetScope { enabled: Boolean, containerColor: Color, expandable: Boolean, + overlay: (@Composable () -> Unit)?, + blurRadius: () -> Dp, content: @Composable () -> Unit ) { - entries[key] = Entry(enabled, containerColor, expandable, content) + entries[key] = Entry(enabled, containerColor, expandable, overlay, blurRadius, content) } fun isEnabled(key: KeyboardSheetKey?): Boolean = key != null && entries[key]?.enabled == true @@ -48,6 +60,14 @@ internal class KeyboardSheetRegistry : KeyboardSheetScope { return enabledEntry(key)?.content } + fun overlayFor(key: KeyboardSheetKey?): (@Composable () -> Unit)? { + return enabledEntry(key)?.overlay + } + + fun blurRadiusFor(key: KeyboardSheetKey?): () -> Dp { + return enabledEntry(key)?.blurRadius ?: { 0.dp } + } + fun containerColorFor(key: KeyboardSheetKey?): Color { return enabledEntry(key)?.containerColor ?: Color.Unspecified } @@ -62,6 +82,8 @@ internal class KeyboardSheetRegistry : KeyboardSheetScope { val enabled: Boolean, val containerColor: Color, val expandable: Boolean, + val overlay: (@Composable () -> Unit)?, + val blurRadius: () -> Dp, val content: @Composable () -> Unit ) } diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreen.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreen.kt index 08fda4bf9e..6db9a86e8f 100644 --- a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreen.kt +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/main/MainScreen.kt @@ -65,6 +65,7 @@ fun MainScreen( is MediaKeyboardAction.EmojiSelected -> onEvent(MainScreenEvents.EmojiSelected(action.emoji)) MediaKeyboardAction.Backspace -> onEvent(MainScreenEvents.BackspacePressed) is MediaKeyboardAction.StickerSelected -> onEvent(MainScreenEvents.StickerSelected(action.sticker)) + is MediaKeyboardAction.StickerSendClicked -> onEvent(MainScreenEvents.StickerSelected(action.sticker)) is MediaKeyboardAction.GifSelected -> onEvent(MainScreenEvents.GifSelected(action.gif)) // Nothing to remember the tab for, and no sticker packs to manage, view, search, send or // remove. diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboard.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboard.kt index af05562b39..8ffafb840c 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboard.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboard.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.StateFlow +import org.signal.core.ui.compose.CollectActions import org.signal.core.ui.compose.keyboard.LocalKeyboardSheetController import org.signal.mediakeyboard.data.MediaKeyboardRepository import org.signal.mediakeyboard.screens.LocalMediaKeyboardExpanded @@ -280,10 +281,12 @@ private fun EmojiTab( ) { val viewModel: EmojiPageViewModel = viewModel( key = "media-keyboard-emoji", - factory = EmojiPageViewModel.Factory(repository.emoji, parentStateFlow, onAction) + factory = EmojiPageViewModel.Factory(repository.emoji, parentStateFlow) ) val state by viewModel.state.collectAsStateWithLifecycle() + CollectActions(viewModel.actions, onAction) + EmojiPageScreen( state = state, onEvent = viewModel::onEvent, @@ -299,10 +302,12 @@ private fun StickerTab( ) { val viewModel: StickerPageViewModel = viewModel( key = "media-keyboard-sticker", - factory = StickerPageViewModel.Factory(repository.stickers, onAction) + factory = StickerPageViewModel.Factory(repository.stickers) ) val state by viewModel.state.collectAsStateWithLifecycle() + CollectActions(viewModel.actions, onAction) + StickerPageScreen( state = state, onEvent = viewModel::onEvent, @@ -318,10 +323,12 @@ private fun GifTab( ) { val viewModel: GifPageViewModel = viewModel( key = "media-keyboard-gif", - factory = GifPageViewModel.Factory(repository.gifs, onAction) + factory = GifPageViewModel.Factory(repository.gifs) ) val state by viewModel.state.collectAsStateWithLifecycle() + CollectActions(viewModel.actions, onAction) + GifPageScreen( state = state, onEvent = viewModel::onEvent, diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardAction.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardAction.kt index ebd47fdee1..fd68a85c06 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardAction.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardAction.kt @@ -7,6 +7,7 @@ package org.signal.mediakeyboard import org.signal.mediakeyboard.data.KeyboardGif import org.signal.mediakeyboard.data.KeyboardSticker +import org.signal.mediakeyboard.data.StickerKeyboardRepository /** * Side effects emitted by a [MediaKeyboard] that need to be handled by the user of the component: @@ -24,9 +25,15 @@ sealed interface MediaKeyboardAction { /** Delete backwards from wherever the host's own field has its cursor. */ data object Backspace : MediaKeyboardAction - /** @param sticker The sticker to send. */ + /** + * The user tapped a sticker. Nothing is sent or recorded yet, so the host can ask first, then + * report a send through [StickerKeyboardRepository.onStickerUsed]. + */ data class StickerSelected(val sticker: KeyboardSticker) : MediaKeyboardAction + /** @param sticker The sticker to send right away, picked from its long press menu. */ + data class StickerSendClicked(val sticker: KeyboardSticker) : MediaKeyboardAction + /** @param gif The gif to send. */ data class GifSelected(val gif: KeyboardGif) : MediaKeyboardAction diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/data/StickerKeyboardRepository.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/data/StickerKeyboardRepository.kt index ff5ba2d666..6481d1a18a 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/data/StickerKeyboardRepository.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/data/StickerKeyboardRepository.kt @@ -26,7 +26,7 @@ interface StickerKeyboardRepository { fun observeStickerPacks(): Flow> /** - * Called whenever the user picks a sticker, so that recents can be tracked. + * Called whenever the user sends a sticker, so that recents can be tracked. */ fun onStickerUsed(sticker: KeyboardSticker) = Unit diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModel.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModel.kt index 631a64a123..f0ce56a77d 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModel.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModel.kt @@ -9,11 +9,14 @@ import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.logging.Log import org.signal.mediakeyboard.MediaKeyboardAction @@ -25,8 +28,7 @@ import org.signal.mediakeyboard.data.EmojiKeyboardRepository class EmojiPageViewModel( private val repository: EmojiKeyboardRepository, - private val parentState: StateFlow, - private val onAction: (MediaKeyboardAction) -> Unit + private val parentState: StateFlow ) : EventDrivenViewModel(TAG, shouldLogEvents = false) { companion object { @@ -36,6 +38,11 @@ class EmojiPageViewModel( private val _state = MutableStateFlow(EmojiPageState()) val state: StateFlow = _state.asStateFlow() + private val actionChannel = Channel(Channel.UNLIMITED) + + /** What the user did that only the host can carry out, for whichever host is current. */ + val actions: Flow = actionChannel.receiveAsFlow() + init { onEvent(EmojiPageScreenEvents.Initialize) parentState @@ -86,7 +93,7 @@ class EmojiPageViewModel( is EmojiPageScreenEvents.EmojiClicked -> { val display = state.displayEmoji(event.emoji) repository.onEmojiUsed(display) - onAction(MediaKeyboardAction.EmojiSelected(display)) + actionChannel.trySend(MediaKeyboardAction.EmojiSelected(display)) } is EmojiPageScreenEvents.EmojiLongPressed -> { @@ -98,7 +105,7 @@ class EmojiPageViewModel( is EmojiPageScreenEvents.VariationSelected -> { repository.setPreferredVariation(event.emoji.canonical, event.variation) repository.onEmojiUsed(event.variation) - onAction(MediaKeyboardAction.EmojiSelected(event.variation)) + actionChannel.trySend(MediaKeyboardAction.EmojiSelected(event.variation)) stateEmitter( state.copy( preferredVariations = repository.getPreferredVariations(), @@ -151,12 +158,11 @@ class EmojiPageViewModel( class Factory( private val repository: EmojiKeyboardRepository, - private val parentState: StateFlow, - private val onAction: (MediaKeyboardAction) -> Unit + private val parentState: StateFlow ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return EmojiPageViewModel(repository, parentState, onAction) as T + return EmojiPageViewModel(repository, parentState) as T } } } diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPageViewModel.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPageViewModel.kt index 31764ee1db..70da98b302 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPageViewModel.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPageViewModel.kt @@ -8,9 +8,12 @@ package org.signal.mediakeyboard.screens.gif import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.Result import org.signal.core.util.logging.Log @@ -18,8 +21,7 @@ import org.signal.mediakeyboard.MediaKeyboardAction import org.signal.mediakeyboard.data.GifKeyboardRepository class GifPageViewModel( - private val repository: GifKeyboardRepository, - private val onAction: (MediaKeyboardAction) -> Unit + private val repository: GifKeyboardRepository ) : EventDrivenViewModel(TAG) { companion object { @@ -30,6 +32,11 @@ class GifPageViewModel( private val _state = MutableStateFlow(GifPageState()) val state: StateFlow = _state.asStateFlow() + private val actionChannel = Channel(Channel.UNLIMITED) + + /** What the user did that only the host can carry out, for whichever host is current. */ + val actions: Flow = actionChannel.receiveAsFlow() + init { onEvent(GifPageScreenEvents.Initialize) } @@ -64,11 +71,11 @@ class GifPageViewModel( } is GifPageScreenEvents.GifClicked -> { - onAction(MediaKeyboardAction.GifSelected(event.gif)) + actionChannel.trySend(MediaKeyboardAction.GifSelected(event.gif)) } is GifPageScreenEvents.SearchClicked -> { - onAction(MediaKeyboardAction.GifSearchClicked) + actionChannel.trySend(MediaKeyboardAction.GifSearchClicked) } } } @@ -115,12 +122,11 @@ class GifPageViewModel( } class Factory( - private val repository: GifKeyboardRepository, - private val onAction: (MediaKeyboardAction) -> Unit + private val repository: GifKeyboardRepository ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return GifPageViewModel(repository, onAction) as T + return GifPageViewModel(repository) as T } } } diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreen.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreen.kt index 151c2beb19..a0f3266b6b 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreen.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreen.kt @@ -409,7 +409,7 @@ private fun StickerCell( imageVector = SignalIcons.Send.imageVector, stringResId = R.string.MediaKeyboard__send, onClick = { - onEvent(StickerPageScreenEvents.StickerClicked(sticker)) + onEvent(StickerPageScreenEvents.StickerSendClicked(sticker)) } ) diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreenEvents.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreenEvents.kt index 138d372d0e..36652ba5dc 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreenEvents.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreenEvents.kt @@ -15,6 +15,7 @@ sealed interface StickerPageScreenEvents { data class VisiblePackChanged(val packId: String) : StickerPageScreenEvents data object ScrollTargetConsumed : StickerPageScreenEvents data class StickerClicked(val sticker: KeyboardSticker) : StickerPageScreenEvents + data class StickerSendClicked(val sticker: KeyboardSticker) : StickerPageScreenEvents data object SearchClicked : StickerPageScreenEvents data class ViewStickerPackClicked(val packId: String, val packKey: String) : StickerPageScreenEvents data class SendStickerPackClicked(val packId: String, val packKey: String) : StickerPageScreenEvents diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModel.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModel.kt index 704e78b768..bc21878116 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModel.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModel.kt @@ -9,19 +9,21 @@ import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.logging.Log import org.signal.mediakeyboard.MediaKeyboardAction import org.signal.mediakeyboard.data.StickerKeyboardRepository class StickerPageViewModel( - private val repository: StickerKeyboardRepository, - private val onAction: (MediaKeyboardAction) -> Unit + private val repository: StickerKeyboardRepository ) : EventDrivenViewModel(TAG, shouldLogEvents = false) { companion object { @@ -31,6 +33,11 @@ class StickerPageViewModel( private val _state = MutableStateFlow(StickerPageState(allowAnimation = repository.allowStickerAnimation)) val state: StateFlow = _state.asStateFlow() + private val actionChannel = Channel(Channel.UNLIMITED) + + /** What the user did that only the host can carry out, for whichever host is current. */ + val actions: Flow = actionChannel.receiveAsFlow() + init { onEvent(StickerPageScreenEvents.Initialize) repository.observeStickerPacks() @@ -72,20 +79,24 @@ class StickerPageViewModel( } is StickerPageScreenEvents.StickerClicked -> { + actionChannel.trySend(MediaKeyboardAction.StickerSelected(event.sticker)) + } + + is StickerPageScreenEvents.StickerSendClicked -> { repository.onStickerUsed(event.sticker) - onAction(MediaKeyboardAction.StickerSelected(event.sticker)) + actionChannel.trySend(MediaKeyboardAction.StickerSendClicked(event.sticker)) } is StickerPageScreenEvents.SearchClicked -> { - onAction(MediaKeyboardAction.StickerSearchClicked) + actionChannel.trySend(MediaKeyboardAction.StickerSearchClicked) } is StickerPageScreenEvents.ViewStickerPackClicked -> { - onAction(MediaKeyboardAction.ViewStickerPackClicked(event.packId, event.packKey)) + actionChannel.trySend(MediaKeyboardAction.ViewStickerPackClicked(event.packId, event.packKey)) } is StickerPageScreenEvents.SendStickerPackClicked -> { - onAction(MediaKeyboardAction.SendStickerPackClicked(event.packId, event.packKey)) + actionChannel.trySend(MediaKeyboardAction.SendStickerPackClicked(event.packId, event.packKey)) } is StickerPageScreenEvents.RemoveStickerPackClicked -> { @@ -97,7 +108,7 @@ class StickerPageViewModel( stateEmitter(state.copy(confirmRemovePack = null)) if (pack != null) { - onAction(MediaKeyboardAction.RemoveStickerPackConfirmed(pack.packId, pack.packKey)) + actionChannel.trySend(MediaKeyboardAction.RemoveStickerPackConfirmed(pack.packId, pack.packKey)) } } @@ -112,12 +123,11 @@ class StickerPageViewModel( } class Factory( - private val repository: StickerKeyboardRepository, - private val onAction: (MediaKeyboardAction) -> Unit + private val repository: StickerKeyboardRepository ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return StickerPageViewModel(repository, onAction) as T + return StickerPageViewModel(repository) as T } } } diff --git a/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModelTest.kt b/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModelTest.kt index 720620aa78..68febeb7e1 100644 --- a/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModelTest.kt +++ b/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/emoji/EmojiPageViewModelTest.kt @@ -5,6 +5,7 @@ package org.signal.mediakeyboard.screens.emoji +import androidx.lifecycle.viewModelScope import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isNotNull @@ -16,6 +17,8 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -60,7 +63,8 @@ class EmojiPageViewModelTest { } private fun createViewModel(): EmojiPageViewModel { - val viewModel = EmojiPageViewModel(repository, parentState, actions::add) + val viewModel = EmojiPageViewModel(repository, parentState) + viewModel.actions.onEach(actions::add).launchIn(viewModel.viewModelScope) testDispatcher.scheduler.advanceUntilIdle() return viewModel } diff --git a/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/gif/GifPageViewModelTest.kt b/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/gif/GifPageViewModelTest.kt index 90a45a661a..2a224d61d0 100644 --- a/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/gif/GifPageViewModelTest.kt +++ b/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/gif/GifPageViewModelTest.kt @@ -5,6 +5,7 @@ package org.signal.mediakeyboard.screens.gif +import androidx.lifecycle.viewModelScope import assertk.assertThat import assertk.assertions.hasSize import assertk.assertions.isEqualTo @@ -14,6 +15,8 @@ import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -49,7 +52,8 @@ class GifPageViewModelTest { } private fun createViewModel(): GifPageViewModel { - val viewModel = GifPageViewModel(repository, actions::add) + val viewModel = GifPageViewModel(repository) + viewModel.actions.onEach(actions::add).launchIn(viewModel.viewModelScope) testDispatcher.scheduler.advanceUntilIdle() return viewModel } diff --git a/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModelTest.kt b/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModelTest.kt index 2ff7fcd247..6fe6daeaab 100644 --- a/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModelTest.kt +++ b/feature/media-keyboard/src/test/java/org/signal/mediakeyboard/screens/sticker/StickerPageViewModelTest.kt @@ -5,6 +5,7 @@ package org.signal.mediakeyboard.screens.sticker +import androidx.lifecycle.viewModelScope import assertk.assertThat import assertk.assertions.isEmpty import assertk.assertions.isEqualTo @@ -15,6 +16,8 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -58,7 +61,8 @@ class StickerPageViewModelTest { } private fun createViewModel(): StickerPageViewModel { - val viewModel = StickerPageViewModel(repository, actions::add) + val viewModel = StickerPageViewModel(repository) + viewModel.actions.onEach(actions::add).launchIn(viewModel.viewModelScope) testDispatcher.scheduler.advanceUntilIdle() return viewModel } @@ -93,15 +97,25 @@ class StickerPageViewModelTest { } @Test - fun `sticker clicked - records the use and reports the selection`() { + fun `sticker clicked - reports the selection without recording a use`() { val viewModel = createViewModel() viewModel.onEvent(StickerPageScreenEvents.StickerClicked(sticker)) testDispatcher.scheduler.advanceUntilIdle() - verify { repository.onStickerUsed(sticker) } + verify(exactly = 0) { repository.onStickerUsed(any()) } assertThat(actions).isEqualTo(listOf(MediaKeyboardAction.StickerSelected(sticker))) } + @Test + fun `sticker send clicked - records the use and asks for a send`() { + val viewModel = createViewModel() + viewModel.onEvent(StickerPageScreenEvents.StickerSendClicked(sticker)) + testDispatcher.scheduler.advanceUntilIdle() + + verify { repository.onStickerUsed(sticker) } + assertThat(actions).isEqualTo(listOf(MediaKeyboardAction.StickerSendClicked(sticker))) + } + @Test fun `view pack clicked - hands the pack's ids to the host`() { val viewModel = createViewModel()