Add sticker confirmation overlay.

This commit is contained in:
Alex Hart
2026-09-29 12:59:29 -03:00
committed by GitHub
parent 4f6173ad4a
commit 5b2e951481
19 changed files with 592 additions and 66 deletions
@@ -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,
@@ -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<StickerConfirmation?>(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
)
@@ -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?
)
@@ -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 {
+8
View File
@@ -511,6 +511,14 @@
<item quantity="other">%d unread messages</item>
</plurals>
<!-- ChatStickerConfirmation -->
<!-- Header shown above a sticker the user is about to send as a reply. %1$s is the name of the person whose message is being replied to. -->
<string name="ChatStickerConfirmation__reply_to_s">Reply to %1$s</string>
<!-- Header shown above a sticker the user is about to send as a reply to one of their own messages -->
<string name="ChatStickerConfirmation__reply_to_yourself">Reply to yourself</string>
<!-- Content description for the button that closes the sticker confirmation without sending -->
<string name="ChatStickerConfirmation__go_back">Go back</string>
<!-- ConversationFragment -->
<!-- Toast text when contacts activity is not found -->
<string name="ConversationFragment__contacts_app_not_found">Contacts app not found.</string>
@@ -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()
}
}
}
@@ -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
)
}
@@ -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.
@@ -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,
@@ -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
@@ -26,7 +26,7 @@ interface StickerKeyboardRepository {
fun observeStickerPacks(): Flow<List<KeyboardStickerPack>>
/**
* 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
@@ -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<MediaKeyboardState>,
private val onAction: (MediaKeyboardAction) -> Unit
private val parentState: StateFlow<MediaKeyboardState>
) : EventDrivenViewModel<EmojiPageScreenEvents>(TAG, shouldLogEvents = false) {
companion object {
@@ -36,6 +38,11 @@ class EmojiPageViewModel(
private val _state = MutableStateFlow(EmojiPageState())
val state: StateFlow<EmojiPageState> = _state.asStateFlow()
private val actionChannel = Channel<MediaKeyboardAction>(Channel.UNLIMITED)
/** What the user did that only the host can carry out, for whichever host is current. */
val actions: Flow<MediaKeyboardAction> = 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<MediaKeyboardState>,
private val onAction: (MediaKeyboardAction) -> Unit
private val parentState: StateFlow<MediaKeyboardState>
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return EmojiPageViewModel(repository, parentState, onAction) as T
return EmojiPageViewModel(repository, parentState) as T
}
}
}
@@ -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<GifPageScreenEvents>(TAG) {
companion object {
@@ -30,6 +32,11 @@ class GifPageViewModel(
private val _state = MutableStateFlow(GifPageState())
val state: StateFlow<GifPageState> = _state.asStateFlow()
private val actionChannel = Channel<MediaKeyboardAction>(Channel.UNLIMITED)
/** What the user did that only the host can carry out, for whichever host is current. */
val actions: Flow<MediaKeyboardAction> = 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 <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return GifPageViewModel(repository, onAction) as T
return GifPageViewModel(repository) as T
}
}
}
@@ -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))
}
)
@@ -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
@@ -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<StickerPageScreenEvents>(TAG, shouldLogEvents = false) {
companion object {
@@ -31,6 +33,11 @@ class StickerPageViewModel(
private val _state = MutableStateFlow(StickerPageState(allowAnimation = repository.allowStickerAnimation))
val state: StateFlow<StickerPageState> = _state.asStateFlow()
private val actionChannel = Channel<MediaKeyboardAction>(Channel.UNLIMITED)
/** What the user did that only the host can carry out, for whichever host is current. */
val actions: Flow<MediaKeyboardAction> = 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 <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return StickerPageViewModel(repository, onAction) as T
return StickerPageViewModel(repository) as T
}
}
}
@@ -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
}
@@ -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
}
@@ -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()