Add new story reaction bar.

This commit is contained in:
Clark 2023-01-30 16:14:16 -05:00 committed by Greyson Parrelli
parent 4677f207e7
commit ef9cd2515e
6 changed files with 201 additions and 171 deletions

View file

@ -48,7 +48,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends FixedRound
EmojiPageViewGridAdapter.VariationSelectorListener
{
private static final String REACTION_STORAGE_KEY = "reactions_recent_emoji";
public static final String REACTION_STORAGE_KEY = "reactions_recent_emoji";
private static final String ABOUT_STORAGE_KEY = TextSecurePreferences.RECENT_STORAGE_KEY;
private static final String ARG_MESSAGE_ID = "arg_message_id";

View file

@ -1,27 +1,40 @@
package org.thoughtcrime.securesms.stories.viewer.reply.composer
import android.content.Context
import android.graphics.Rect
import android.net.Uri
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.OvershootInterpolator
import android.view.inputmethod.EditorInfo
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.ViewSwitcher
import androidx.core.view.marginEnd
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.components.InputAwareLayout
import org.thoughtcrime.securesms.components.QuoteView
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.EmojiPageView
import org.thoughtcrime.securesms.components.emoji.EmojiToggle
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.emoji.EmojiSource
import org.thoughtcrime.securesms.keyboard.emoji.toMappingModels
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
class StoryReplyComposer @JvmOverloads constructor(
context: Context,
@ -30,13 +43,15 @@ class StoryReplyComposer @JvmOverloads constructor(
) : FrameLayout(context, attrs, defStyleAttr) {
private val inputAwareLayout: InputAwareLayout
private val quoteView: QuoteView
private val privacyChrome: TextView
private val emojiDrawerToggle: EmojiToggle
private val emojiDrawer: MediaKeyboard
private val reactionEmojiView: EmojiPageView
private val anyReactionView: View
private val emojiBar: View
private val bubbleView: ViewGroup
val reactionButton: View
val input: ComposeText
val decoration: SpacingDecoration
var isRequestingEmojiDrawer: Boolean = false
private set
@ -51,19 +66,18 @@ class StoryReplyComposer @JvmOverloads constructor(
inputAwareLayout = findViewById(R.id.input_aware_layout)
emojiDrawerToggle = findViewById(R.id.emoji_toggle)
quoteView = findViewById(R.id.quote_view)
input = findViewById(R.id.compose_text)
reactionButton = findViewById(R.id.reaction)
privacyChrome = findViewById(R.id.private_reply_recipient)
emojiDrawer = findViewById(R.id.emoji_drawer)
anyReactionView = findViewById(R.id.any_reaction)
reactionEmojiView = findViewById(R.id.reaction_emoji_view)
emojiBar = findViewById(R.id.emoji_bar)
bubbleView = findViewById(R.id.bubble)
val viewSwitcher: ViewSwitcher = findViewById(R.id.reply_reaction_switch)
val reply: View = findViewById(R.id.reply)
reply.setOnClickListener {
callback?.onSendActionClicked()
}
input.setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_SEND -> {
@ -74,16 +88,21 @@ class StoryReplyComposer @JvmOverloads constructor(
}
}
input.doAfterTextChanged {
if (it.isNullOrEmpty()) {
viewSwitcher.displayedChild = 0
} else {
viewSwitcher.displayedChild = 1
}
anyReactionView.setOnClickListener {
callback?.onPickAnyReactionClicked()
}
reactionButton.setOnClickListener {
callback?.onPickReactionClicked()
input.doAfterTextChanged {
val notEmpty = !it.isNullOrEmpty()
reply.isEnabled = notEmpty
if (notEmpty && reply.visibility != View.VISIBLE) {
val transition = AutoTransition().setDuration(200L).setInterpolator(OvershootInterpolator(1f))
TransitionManager.beginDelayedTransition(bubbleView, transition)
reply.visibility = View.VISIBLE
reply.scaleX = 0f
reply.scaleY = 0f
reply.animate().setDuration(150).scaleX(1f).scaleY(1f).setInterpolator(OvershootInterpolator(1f)).start()
}
}
emojiDrawerToggle.setOnClickListener {
@ -95,6 +114,29 @@ class StoryReplyComposer @JvmOverloads constructor(
onEmojiToggleClicked()
}
}
val emojiEventListener: EmojiEventListener = object : EmojiEventListener {
override fun onEmojiSelected(emoji: String?) {
if (emoji != null) {
callback?.onReactionClicked(emoji)
}
}
override fun onKeyEvent(keyEvent: KeyEvent?) = Unit
}
reactionEmojiView.initialize(
emojiEventListener,
{ },
false,
LinearLayoutManager(context, RecyclerView.HORIZONTAL, false),
R.layout.emoji_display_item_list,
R.layout.emoji_text_display_item_list
)
decoration = SpacingDecoration()
reactionEmojiView.addItemDecoration(decoration)
reactionEmojiView.setList(getReactionEmojis()) {
updateEmojiSpacing()
}
}
var hint: CharSequence
@ -105,24 +147,8 @@ class StoryReplyComposer @JvmOverloads constructor(
input.hint = value
}
fun setQuote(messageRecord: MediaMmsMessageRecord) {
quoteView.setQuote(
GlideApp.with(this),
messageRecord.dateSent,
messageRecord.recipient,
messageRecord.body,
false,
messageRecord.slideDeck,
null,
QuoteModel.Type.NORMAL
)
quoteView.visible = true
}
fun displayPrivacyChrome(recipient: Recipient) {
privacyChrome.text = context.getString(R.string.StoryReplyComposer__replying_privately_to_s, recipient.getDisplayName(context))
privacyChrome.visible = true
fun displayReplyHint(recipient: Recipient) {
input.hint = (context.getString(R.string.StoryReplyComposer__reply_to_s, recipient.getDisplayName(context)))
}
fun consumeInput(): Input {
@ -151,6 +177,17 @@ class StoryReplyComposer @JvmOverloads constructor(
inputAwareLayout.hideCurrentInput(input)
}
private fun getReactionEmojis(): List<MappingModel<*>> {
val reactionEmoji = SignalStore.emojiValues().reactions
val recentEmoji = RecentEmojiPageModel(context, ReactWithAnyEmojiBottomSheetDialogFragment.REACTION_STORAGE_KEY).emoji
val emoji = (reactionEmoji + recentEmoji).distinct()
val displayEmoji: List<Emoji> = emoji
.mapNotNull { canonical -> EmojiSource.latest.canonicalToVariations[canonical] }
.map { Emoji(it) }
return EmojiReactionsPageModel(emoji, displayEmoji).toMappingModels()
}
private fun onEmojiToggleClicked() {
if (!emojiDrawer.isInitialised) {
callback?.onInitializeEmojiDrawer(emojiDrawer)
@ -168,13 +205,60 @@ class StoryReplyComposer @JvmOverloads constructor(
}
}
private fun updateEmojiSpacing() {
val emojiItemWidth = 44.dp
val availableWidth = reactionEmojiView.width - anyReactionView.marginEnd
val maxNumItems = availableWidth / emojiItemWidth
val numItems = reactionEmojiView.adapter?.itemCount ?: 0
decoration.firstItemOffset = anyReactionView.marginEnd
if (numItems > maxNumItems) {
decoration.horizontalSpacing = 0
reactionEmojiView.invalidateItemDecorations()
} else {
decoration.horizontalSpacing = (availableWidth - (numItems * emojiItemWidth)) / numItems
reactionEmojiView.invalidateItemDecorations()
}
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
updateEmojiSpacing()
}
interface Callback {
fun onSendActionClicked()
fun onPickReactionClicked()
fun onPickAnyReactionClicked()
fun onReactionClicked(emoji: String)
fun onInitializeEmojiDrawer(mediaKeyboard: MediaKeyboard)
fun onShowEmojiKeyboard() = Unit
fun onHideEmojiKeyboard() = Unit
}
class SpacingDecoration : RecyclerView.ItemDecoration() {
var horizontalSpacing: Int = 0
var firstItemOffset: Int = 0
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
outRect.right = horizontalSpacing
if (parent.getChildAdapterPosition(view) == 0) {
outRect.left = firstItemOffset
} else {
outRect.left = 0
}
}
}
private class EmojiReactionsPageModel(private val emoji: List<String>, private val displayEmoji: List<Emoji>) : EmojiPageModel {
override fun getKey(): String = ""
override fun getIconAttr(): Int = -1
override fun getEmoji(): List<String> = emoji
override fun getDisplayEmoji(): List<Emoji> = displayEmoji
override fun getSpriteUri(): Uri? = null
override fun isDynamic(): Boolean = false
}
data class Input(val body: String, val mentions: List<Mention>, val bodyRanges: BodyRangeList?)
}

View file

@ -13,7 +13,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
@ -21,9 +20,7 @@ import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel
import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar
import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReplyComposer
import org.thoughtcrime.securesms.util.FragmentDialogs.displayInDialogAboveAnchor
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
@ -82,28 +79,13 @@ class StoryDirectReplyDialogFragment :
}
}
override fun onPickReactionClicked() {
displayInDialogAboveAnchor(composer.reactionButton, R.layout.stories_reaction_bar_layout) { dialog, view ->
view.findViewById<StoryReactionBar>(R.id.reaction_bar).apply {
callback = object : StoryReactionBar.Callback {
override fun onTouchOutsideOfReactionBar() {
dialog.dismiss()
}
override fun onReactionClicked(emoji: String) {
sendReaction(emoji)
}
override fun onReactionSelected(emoji: String) {
dialog.dismiss()
sendReaction(emoji)
}
override fun onOpenReactionPicker() {
dialog.dismiss()
isRequestingReactWithAny = true
ReactWithAnyEmojiBottomSheetDialogFragment.createForStory().show(childFragmentManager, null)
}
}
animateIn()
}
}
override fun onPickAnyReactionClicked() {
isRequestingReactWithAny = true
ReactWithAnyEmojiBottomSheetDialogFragment.createForStory().show(childFragmentManager, null)
}
override fun onInitializeEmojiDrawer(mediaKeyboard: MediaKeyboard) {
@ -114,13 +96,9 @@ class StoryDirectReplyDialogFragment :
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.groupDirectReplyRecipient != null) {
composer.displayPrivacyChrome(state.groupDirectReplyRecipient)
composer.displayReplyHint(state.groupDirectReplyRecipient)
} else if (state.storyRecord != null) {
composer.displayPrivacyChrome(state.storyRecord.recipient)
}
if (state.storyRecord != null) {
composer.setQuote(state.storyRecord as MediaMmsMessageRecord)
composer.displayReplyHint(state.storyRecord.recipient)
}
}
}

View file

@ -53,10 +53,8 @@ import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent
import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReactionBar
import org.thoughtcrime.securesms.stories.viewer.reply.composer.StoryReplyComposer
import org.thoughtcrime.securesms.util.DeleteDialog
import org.thoughtcrime.securesms.util.FragmentDialogs.displayInDialogAboveAnchor
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
@ -355,27 +353,12 @@ class StoryGroupReplyFragment :
performSend(body, mentions, bodyRanges)
}
override fun onPickReactionClicked() {
displayInDialogAboveAnchor(composer.reactionButton, R.layout.stories_reaction_bar_layout) { dialog, view ->
view.findViewById<StoryReactionBar>(R.id.reaction_bar).apply {
callback = object : StoryReactionBar.Callback {
override fun onTouchOutsideOfReactionBar() {
dialog.dismiss()
}
override fun onPickAnyReactionClicked() {
ReactWithAnyEmojiBottomSheetDialogFragment.createForStory().show(childFragmentManager, null)
}
override fun onReactionSelected(emoji: String) {
dialog.dismiss()
sendReaction(emoji)
}
override fun onOpenReactionPicker() {
dialog.dismiss()
ReactWithAnyEmojiBottomSheetDialogFragment.createForStory().show(childFragmentManager, null)
}
}
animateIn()
}
}
override fun onReactionClicked(emoji: String) {
sendReaction(emoji)
}
override fun onEmojiSelected(emoji: String?) {

View file

@ -6,62 +6,73 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/signal_colorSurface"
android:paddingTop="12dp">
android:paddingTop="10dp">
<org.thoughtcrime.securesms.components.FromTextView
android:id="@+id/private_reply_recipient"
<LinearLayout
android:id="@+id/emoji_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="6dp"
android:textAppearance="@style/Signal.Text.Caption"
android:textColor="@color/signal_colorOnSurfaceVariant"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/bubble"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Replying privately to Miles Morales"
tools:visibility="visible" />
android:orientation="horizontal">
<androidx.constraintlayout.widget.ConstraintLayout
<org.thoughtcrime.securesms.components.emoji.EmojiPageView
android:id="@+id/reaction_emoji_view"
android:layout_width="0dp"
android:layout_weight="1"
android:requiresFadingEdge="horizontal"
android:fadingEdgeLength="8dp"
android:layout_height="wrap_content"/>
<ImageView
android:id="@+id/any_reaction"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="16dp"
android:layout_gravity="center_vertical"
android:src="@drawable/ic_any_emoji_32"
/>
</LinearLayout>
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/bubble"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="8dp"
android:background="@drawable/rounded_rectangle_surface_variant_18"
android:background="@drawable/rounded_rectangle_surface_variant_32"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reply_reaction_switch"
app:layout_constraintEnd_toStartOf="@id/reply"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/private_reply_recipient"
app:layout_goneMarginTop="0dp">
app:layout_constraintTop_toBottomOf="@id/emoji_bar"
app:layout_goneMarginTop="0dp"
app:layout_goneMarginEnd="16dp"
android:orientation="horizontal">
<org.thoughtcrime.securesms.components.QuoteView
android:id="@+id/quote_view"
android:layout_width="match_parent"
<org.thoughtcrime.securesms.components.emoji.EmojiToggle
android:id="@+id/emoji_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:message_type="story_reply_preview"
app:quote_colorPrimary="@color/signal_text_primary"
app:quote_colorSecondary="@color/signal_text_primary"
tools:visibility="gone" />
android:background="?selectableItemBackgroundBorderless"
app:force_outline="true"
android:layout_gravity="bottom"
app:tint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<org.thoughtcrime.securesms.components.ComposeText
android:id="@+id/compose_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:background="@null"
android:hint="@string/StoryViewerPageFragment__reply"
android:layout_gravity="center_vertical"
android:imeOptions="flagNoEnterAction|actionSend"
android:inputType="textAutoCorrect|textCapSentences|textMultiLine"
android:maxLength="65536"
@ -71,53 +82,25 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/emoji_toggle"
app:layout_constraintTop_toBottomOf="@id/quote_view"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginTop="0dp"
tools:text="hello\nasdf" />
<org.thoughtcrime.securesms.components.emoji.EmojiToggle
android:id="@+id/emoji_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="?selectableItemBackgroundBorderless"
app:force_outline="true"
app:tint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_goneMarginTop="0dp" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.constraintlayout.widget.ConstraintLayout>
<ViewSwitcher
android:id="@+id/reply_reaction_switch"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="6dp"
android:layout_marginBottom="2dp"
<ImageView
android:id="@+id/reply"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="bottom"
android:background="@drawable/circle_tintable"
android:contentDescription="@string/StoryReplyComposer__react_to_this_story"
android:padding="8dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
app:backgroundTint="@color/signal_light_colorPrimary"
app:srcCompat="@drawable/ic_send_24"
app:layout_constraintBottom_toBottomOf="@+id/bubble"
app:layout_constraintEnd_toEndOf="parent">
<ImageView
android:id="@+id/reaction"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/StoryReplyComposer__react_to_this_story"
android:padding="6dp"
app:srcCompat="@drawable/ic_add_reaction_outline_24"
app:tint="@color/signal_colorOnSurface" />
<ImageView
android:id="@+id/reply"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="bottom"
android:background="@drawable/circle_tintable"
android:contentDescription="@string/StoryReplyComposer__react_to_this_story"
android:padding="6dp"
app:backgroundTint="@color/signal_light_colorPrimary"
app:srcCompat="@drawable/ic_send_24" />
</ViewSwitcher>
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -4861,6 +4861,8 @@
<string name="StoryReplyComposer__react_to_this_story">React to this story</string>
<!-- Displayed when the user is replying privately to someone who replied to one of their stories -->
<string name="StoryReplyComposer__replying_privately_to_s">Replying privately to %1$s</string>
<!-- Displayed when the user is replying privately to someone who replied to one of their stories -->
<string name="StoryReplyComposer__reply_to_s">Reply to %1$s</string>
<!-- Context menu item to privately reply to a story response -->
<!-- Context menu item to copy a story response -->
<string name="StoryGroupReplyItem__copy">Copy</string>