mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-30 00:29:00 +01:00
Add sticker confirmation overlay.
This commit is contained in:
@@ -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,
|
||||
|
||||
+243
@@ -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
|
||||
)
|
||||
+60
@@ -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?
|
||||
)
|
||||
+99
-8
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+46
-21
@@ -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,
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+13
-7
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+20
-10
@@ -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
-1
@@ -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
-1
@@ -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
|
||||
}
|
||||
|
||||
+17
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user