Add initial CFV2 long press state implementation.

This commit is contained in:
Alex Hart 2023-05-23 14:14:50 -03:00 committed by Nicholas
parent 145377b05f
commit f961f4ccac
11 changed files with 639 additions and 34 deletions

View file

@ -486,6 +486,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return SWIPE_RECT.contains((int) downX, (int) downY);
}
public @Nullable ConversationItemBodyBubble getBodyBubble() {
return bodyBubble;
}
public @Nullable View getQuotedIndicator() {
return quotedIndicator;
}
public @Nullable ReactionsConversationView getReactionsView() {
return reactionsView;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

View file

@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
* respecting listeners and other positional information that can be set BEFORE we want to actually
* resolve the view.
*/
final class ConversationReactionDelegate {
public final class ConversationReactionDelegate {
private final Stub<ConversationReactionOverlay> overlayStub;
private final PointF lastSeenDownPoint = new PointF();
@ -26,15 +26,15 @@ final class ConversationReactionDelegate {
private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener;
private ConversationReactionOverlay.OnHideListener onHideListener;
ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
public ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
this.overlayStub = overlayStub;
}
boolean isShowing() {
public boolean isShowing() {
return overlayStub.resolved() && overlayStub.get().isShowing();
}
void show(@NonNull Activity activity,
public void show(@NonNull Activity activity,
@NonNull Recipient conversationRecipient,
@NonNull ConversationMessage conversationMessage,
boolean isNonAdminInAnnouncementGroup,
@ -43,15 +43,15 @@ final class ConversationReactionDelegate {
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup, selectedConversationModel);
}
void hide() {
public void hide() {
overlayStub.get().hide();
}
void hideForReactWithAny() {
public void hideForReactWithAny() {
overlayStub.get().hideForReactWithAny();
}
void setOnReactionSelectedListener(@NonNull ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener) {
public void setOnReactionSelectedListener(@NonNull ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener) {
this.onReactionSelectedListener = onReactionSelectedListener;
if (overlayStub.resolved()) {
@ -59,7 +59,7 @@ final class ConversationReactionDelegate {
}
}
void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
public void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener;
if (overlayStub.resolved()) {
@ -67,7 +67,7 @@ final class ConversationReactionDelegate {
}
}
void setOnHideListener(@NonNull ConversationReactionOverlay.OnHideListener onHideListener) {
public void setOnHideListener(@NonNull ConversationReactionOverlay.OnHideListener onHideListener) {
this.onHideListener = onHideListener;
if (overlayStub.resolved()) {
@ -75,7 +75,7 @@ final class ConversationReactionDelegate {
}
}
@NonNull MessageRecord getMessageRecord() {
public @NonNull MessageRecord getMessageRecord() {
if (!overlayStub.resolved()) {
throw new IllegalStateException("Cannot call getMessageRecord right now.");
}
@ -83,7 +83,7 @@ final class ConversationReactionDelegate {
return overlayStub.get().getMessageRecord();
}
boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
if (!overlayStub.resolved() || !overlayStub.get().isShowing()) {
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) {
lastSeenDownPoint.set(motionEvent.getX(), motionEvent.getY());

View file

@ -23,7 +23,7 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.constraintlayout.widget.Barrier;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.content.ContextCompat;
@ -33,6 +33,7 @@ import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
@ -408,6 +409,12 @@ public final class ConversationReactionOverlay extends FrameLayout {
}
private int getInputPanelHeight(@NonNull Activity activity) {
if (SignalStore.internalValues().useConversationFragmentV2()) {
Barrier conversationBottomPanelBarrier = activity.findViewById(R.id.conversation_bottom_panel_barrier);
return activity.getResources().getDisplayMetrics().heightPixels - conversationBottomPanelBarrier.getTop();
}
View bottomPanel = activity.findViewById(R.id.conversation_activity_panel_parent);
View emojiDrawer = activity.findViewById(R.id.emoji_drawer);
@ -428,7 +435,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
}
}
@RequiresApi(api = 21)
private void updateSystemUiOnShow(@NonNull Activity activity) {
Window window = activity.getWindow();
int barColor = ContextCompat.getColor(getContext(), R.color.conversation_item_selected_system_ui);

View file

@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import java.util.Set;
import java.util.stream.Collectors;
final class MenuState {
public final class MenuState {
private static final int MAX_FORWARDABLE_COUNT = 32;
@ -41,50 +41,50 @@ final class MenuState {
edit = builder.edit;
}
boolean shouldShowForwardAction() {
public boolean shouldShowForwardAction() {
return forward;
}
boolean shouldShowReplyAction() {
public boolean shouldShowReplyAction() {
return reply;
}
boolean shouldShowDetailsAction() {
public boolean shouldShowDetailsAction() {
return details;
}
boolean shouldShowSaveAttachmentAction() {
public boolean shouldShowSaveAttachmentAction() {
return saveAttachment;
}
boolean shouldShowResendAction() {
public boolean shouldShowResendAction() {
return resend;
}
boolean shouldShowCopyAction() {
public boolean shouldShowCopyAction() {
return copy;
}
boolean shouldShowDeleteAction() {
public boolean shouldShowDeleteAction() {
return delete;
}
boolean shouldShowReactions() {
public boolean shouldShowReactions() {
return reactions;
}
boolean shouldShowPaymentDetails() {
public boolean shouldShowPaymentDetails() {
return paymentDetails;
}
boolean shouldShowEditAction() {
public boolean shouldShowEditAction() {
return edit;
}
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
boolean isNonAdminInAnnouncementGroup)
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
boolean isNonAdminInAnnouncementGroup)
{
Builder builder = new Builder();

View file

@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Intent
import android.view.MotionEvent
import androidx.activity.viewModels
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
@ -15,6 +17,8 @@ class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaController
private val theme = DynamicNoActionBarTheme()
override val voiceNoteMediaController = VoiceNoteMediaController(this, true)
private val motionEventRelay: MotionEventRelay by viewModels()
override fun onPreCreate() {
theme.onCreate(this)
}
@ -32,4 +36,8 @@ class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaController
super.onNewIntent(intent)
error("ON NEW INTENT")
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
}
}

View file

@ -178,6 +178,18 @@ class ConversationAdapterV2(
// todo [cody] implement
}
fun clearSelection() {
_selected.clear()
}
fun toggleSelection(multiselectPart: MultiselectPart) {
if (multiselectPart in _selected) {
_selected.remove(multiselectPart)
} else {
_selected.add(multiselectPart)
}
}
private inner class ConversationUpdateViewHolder(itemView: View) : ConversationViewHolder<ConversationUpdate>(itemView) {
override fun bind(model: ConversationUpdate) {
bindable.setEventListener(clickListener)
@ -306,6 +318,20 @@ class ConversationAdapterV2(
protected val displayMode: ConversationItemDisplayMode
get() = condensedMode ?: ConversationItemDisplayMode.STANDARD
init {
itemView.setOnClickListener {
clickListener.onItemClick(bindable.getMultiselectPartForLatestTouch())
}
itemView.setOnLongClickListener {
clickListener.onItemLongClick(
it,
bindable.getMultiselectPartForLatestTouch()
)
true
}
}
override fun showProjectionArea() {
bindable.showProjectionArea()
}

View file

@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.conversation.v2
import android.annotation.SuppressLint
import android.app.ActivityOptions
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.net.Uri
@ -17,8 +18,10 @@ import android.text.TextWatcher
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.View.OnFocusChangeListener
import android.view.ViewTreeObserver
import android.view.inputmethod.EditorInfo
import android.widget.ImageButton
import android.widget.TextView
@ -27,11 +30,15 @@ import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.doOnPreDraw
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.viewModels
@ -41,6 +48,8 @@ import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar.Duration
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
@ -54,6 +63,7 @@ import org.signal.core.util.Result
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.libsignal.protocol.InvalidMessageException
@ -61,6 +71,8 @@ import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.gifts.OpenableGift
import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration
import org.thoughtcrime.securesms.badges.gifts.flow.GiftFlowActivity
import org.thoughtcrime.securesms.badges.gifts.viewgift.received.ViewReceivedGiftBottomSheet
import org.thoughtcrime.securesms.badges.gifts.viewgift.sent.ViewSentGiftBottomSheet
@ -72,6 +84,8 @@ import org.thoughtcrime.securesms.components.InputPanel
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.SendButton
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
@ -86,10 +100,17 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationItemSelection
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationOptionsMenu
import org.thoughtcrime.securesms.conversation.ConversationReactionDelegate
import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay
import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay.OnHideListener
import org.thoughtcrime.securesms.conversation.MarkReadHelper
import org.thoughtcrime.securesms.conversation.MenuState
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.SelectedConversationModel
import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
@ -104,6 +125,7 @@ import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallVi
import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel
import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
@ -158,18 +180,25 @@ import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DrawableUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture
import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.hasAudio
import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.util.views.Stub
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil
import java.util.Locale
import java.util.concurrent.ExecutionException
/**
* A single unified fragment for Conversations.
@ -232,8 +261,11 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
private lateinit var adapter: ConversationAdapterV2
private lateinit var recyclerViewColorizer: RecyclerViewColorizer
private lateinit var attachmentManager: AttachmentManager
private lateinit var multiselectItemDecoration: MultiselectItemDecoration
private lateinit var openableGiftItemDecoration: OpenableGiftItemDecoration
private var animationsAllowed = false
private var actionMode: ActionMode? = null
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
@ -242,6 +274,10 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
}
private val motionEventRelay: MotionEventRelay by viewModels(ownerProducer = { requireActivity() })
private val actionModeCallback = ActionModeCallback()
private val container: InputAwareConstraintLayout
get() = requireView() as InputAwareConstraintLayout
@ -257,6 +293,11 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
private val sendEditButton: ImageButton
get() = binding.conversationInputPanel.sendEditButton
private val bottomActionBar: SignalBottomActionBar
get() = binding.conversationBottomActionBar
private lateinit var reactionDelegate: ConversationReactionDelegate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SignalLocalMetrics.ConversationOpen.start()
@ -304,11 +345,14 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
if (!args.conversationScreenType.isInBubble) {
ApplicationDependencies.getMessageNotifier().setVisibleThread(ConversationId.forConversation(args.threadId))
}
motionEventRelay.setDrain(MotionEventRelayDrain())
}
override fun onPause() {
super.onPause()
ApplicationDependencies.getMessageNotifier().clearVisibleThread()
motionEventRelay.setDrain(null)
}
private fun observeConversationThread() {
@ -425,6 +469,10 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
)
childFragmentManager.setFragmentResultListener(AttachmentKeyboardFragment.RESULT_KEY, viewLifecycleOwner, AttachmentKeyboardFragmentListener())
val conversationReactionStub = Stub<ConversationReactionOverlay>(binding.conversationReactionScrubberStub)
reactionDelegate = ConversationReactionDelegate(conversationReactionStub)
reactionDelegate.setOnReactionSelectedListener(OnReactionsSelectedListener())
}
private fun presentInputReadyState(inputReadyState: InputReadyState) {
@ -629,10 +677,13 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
binding.conversationItemRecycler.adapter = adapter
giphyMp4ProjectionRecycler = initializeGiphyMp4()
val multiselectItemDecoration = MultiselectItemDecoration(
multiselectItemDecoration = MultiselectItemDecoration(
requireContext()
) { viewModel.wallpaperSnapshot }
openableGiftItemDecoration = OpenableGiftItemDecoration(requireContext())
binding.conversationItemRecycler.addItemDecoration(openableGiftItemDecoration)
binding.conversationItemRecycler.addItemDecoration(multiselectItemDecoration)
viewLifecycleOwner.lifecycle.addObserver(multiselectItemDecoration)
@ -666,7 +717,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
GiphyMp4PlaybackController.attach(binding.conversationItemRecycler, callback, maxPlayback)
binding.conversationItemRecycler.addItemDecoration(
GiphyMp4ItemDecoration(callback) { translationY: Float ->
// TODO [alex] reactionsShade.setTranslationY(translationY + list.getHeight())
binding.reactionsShade.translationY = translationY + binding.conversationItemRecycler.height
},
0
)
@ -776,6 +827,238 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
}
private fun snackbar(
@StringRes text: Int,
anchor: View = binding.conversationItemRecycler,
@Duration duration: Int = Snackbar.LENGTH_LONG
) {
Snackbar.make(anchor, text, duration).show()
}
private fun maybeShowSwipeToReplyTooltip() {
if (!TextSecurePreferences.hasSeenSwipeToReplyTooltip(requireContext())) {
val tooltipText = if (ViewUtil.isLtr(requireContext())) {
R.string.ConversationFragment_you_can_swipe_to_the_right_reply
} else {
R.string.ConversationFragment_you_can_swipe_to_the_left_reply
}
snackbar(tooltipText)
TextSecurePreferences.setHasSeenSwipeToReplyTooltip(requireContext(), true)
}
}
private fun calculateSelectedItemCount(): String {
val count = adapter.selectedItems.map(MultiselectPart::conversationMessage).distinct().count()
return requireContext().resources.getQuantityString(R.plurals.conversation_context__s_selected, count, count)
}
private fun getSelectedConversationMessage(): ConversationMessage {
val records = adapter.selectedItems.map(MultiselectPart::conversationMessage).distinct().toSet()
if (records.size == 1) {
return records.first()
}
error("More than one conversation message in set.")
}
private fun setCorrectActionModeMenuVisibility() {
val selectedParts = adapter.selectedItems
if (actionMode != null && selectedParts.isEmpty()) {
actionMode?.finish()
return
}
setBottomActionBarVisibility(true)
val recipient = viewModel.recipientSnapshot ?: return
val menuState = MenuState.getMenuState(
recipient,
selectedParts,
viewModel.hasMessageRequestState,
conversationGroupViewModel.isNonAdminInAnnouncementGroup()
)
val items = arrayListOf<ActionItem>()
if (menuState.shouldShowReplyAction()) {
items.add(
ActionItem(R.drawable.symbol_reply_24, resources.getString(R.string.conversation_selection__menu_reply)) {
maybeShowSwipeToReplyTooltip()
handleReplyToMessage(getSelectedConversationMessage())
actionMode?.finish()
}
)
}
if (menuState.shouldShowEditAction() && FeatureFlags.editMessageSending()) {
items.add(
ActionItem(R.drawable.symbol_edit_24, resources.getString(R.string.conversation_selection__menu_edit)) {
handleEditMessage(getSelectedConversationMessage())
actionMode?.finish()
}
)
}
if (menuState.shouldShowForwardAction()) {
items.add(
ActionItem(R.drawable.symbol_forward_24, resources.getString(R.string.conversation_selection__menu_forward)) {
handleForwardMessageParts(selectedParts)
}
)
}
if (menuState.shouldShowSaveAttachmentAction()) {
items.add(
ActionItem(R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save)) {
handleSaveAttachment(getSelectedConversationMessage().messageRecord as MediaMmsMessageRecord)
actionMode?.finish()
}
)
}
if (menuState.shouldShowCopyAction()) {
items.add(
ActionItem(R.drawable.symbol_copy_android_24, getResources().getString(R.string.conversation_selection__menu_copy)) {
handleCopyMessage(selectedParts)
actionMode?.finish()
}
)
}
if (menuState.shouldShowDetailsAction()) {
items.add(
ActionItem(R.drawable.symbol_info_24, getResources().getString(R.string.conversation_selection__menu_message_details)) {
handleDisplayDetails(getSelectedConversationMessage())
actionMode?.finish()
}
)
}
if (menuState.shouldShowDeleteAction()) {
items.add(
ActionItem(R.drawable.symbol_trash_24, getResources().getString(R.string.conversation_selection__menu_delete)) {
handleDeleteMessages(selectedParts)
actionMode?.finish()
}
)
}
bottomActionBar.setItems(items)
}
private fun setBottomActionBarVisibility(isVisible: Boolean) {
val isCurrentlyVisible = bottomActionBar.isVisible
if (isVisible == isCurrentlyVisible) {
return
}
val additionalScrollOffset = 54.dp
if (isVisible) {
ViewUtil.animateIn(bottomActionBar, bottomActionBar.enterAnimation)
inputPanel.setHideForSelection(true)
bottomActionBar.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (bottomActionBar.height == 0 && bottomActionBar.visible) {
return false
}
bottomActionBar.viewTreeObserver.removeOnPreDrawListener(this)
val bottomPadding = bottomActionBar.height + 18.dp
ViewUtil.setPaddingBottom(binding.conversationItemRecycler, bottomPadding)
binding.conversationItemRecycler.scrollBy(0, -(bottomPadding - additionalScrollOffset))
return false
}
})
} else {
ViewUtil.animateOut(bottomActionBar, bottomActionBar.exitAnimation)
.addListener(object : ListenableFuture.Listener<Boolean> {
override fun onSuccess(result: Boolean?) {
val scrollOffset = binding.conversationItemRecycler.paddingBottom - additionalScrollOffset
inputPanel.setHideForSelection(false)
val bottomPadding = resources.getDimensionPixelSize(R.dimen.conversation_bottom_padding)
ViewUtil.setPaddingBottom(binding.conversationItemRecycler, bottomPadding)
binding.conversationItemRecycler.doOnPreDraw {
it.scrollBy(0, scrollOffset)
}
}
override fun onFailure(e: ExecutionException?) = Unit
})
}
}
private fun isUnopenedGift(itemView: View, messageRecord: MessageRecord): Boolean {
if (itemView is OpenableGift) {
val projection = (itemView as OpenableGift).getOpenableGiftProjection(false)
if (projection != null) {
projection.release()
return !openableGiftItemDecoration.hasOpenedGiftThisSession(messageRecord.id)
}
}
return false
}
private fun clearFocusedItem() {
multiselectItemDecoration.setFocusedItem(null)
binding.conversationItemRecycler.invalidateItemDecorations()
}
private fun handleReaction(
conversationMessage: ConversationMessage,
onActionSelectedListener: OnActionSelectedListener,
selectedConversationModel: SelectedConversationModel,
onHideListener: OnHideListener
) {
reactionDelegate.setOnActionSelectedListener(onActionSelectedListener)
reactionDelegate.setOnHideListener(onHideListener)
reactionDelegate.show(requireActivity(), viewModel.recipientSnapshot!!, conversationMessage, conversationGroupViewModel.isNonAdminInAnnouncementGroup(), selectedConversationModel)
composeText.clearFocus()
/*
// TODO [alex]
if (attachmentKeyboardStub.resolved()) {
attachmentKeyboardStub.get().hide(true);
}
*/
}
//region Message action handling
private fun handleReplyToMessage(conversationMessage: ConversationMessage) {
// TODO [alex] -- Not implemented yet.
}
private fun handleEditMessage(conversationMessage: ConversationMessage) {
// TODO [alex] -- Not implemented yet.
}
private fun handleForwardMessageParts(messageParts: Set<MultiselectPart>) {
// TODO [alex] -- Not implemented yet.
}
private fun handleSaveAttachment(record: MediaMmsMessageRecord) {
// TODO [alex] -- Not implemented yet.
}
private fun handleCopyMessage(messageParts: Set<MultiselectPart>) {
// TODO [alex] -- Not implemented yet.
}
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {
// TODO [alex] -- Not implemented yet.
}
private fun handleDeleteMessages(messageParts: Set<MultiselectPart>) {
// TODO [alex] -- Not implemented yet.
}
//endregion
//region Scroll Handling
/**
@ -1160,13 +1443,154 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
// TODO [alex] -- ("Not yet implemented")
}
override fun onItemLongClick(itemView: View?, item: MultiselectPart?) {
// TODO [alex] -- ("Not yet implemented")
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
Log.d(TAG, "onItemLongClick")
if (actionMode != null) return
val messageRecord = item.getMessageRecord()
val recipient = viewModel.recipientSnapshot ?: return
if (isUnopenedGift(itemView, messageRecord)) {
return
}
if (messageRecord.isSecure &&
!messageRecord.isRemoteDelete &&
!messageRecord.isUpdate &&
!recipient.isBlocked &&
!viewModel.hasMessageRequestState &&
(!recipient.isGroup || recipient.isActiveGroup) &&
adapter.selectedItems.isEmpty()
) {
multiselectItemDecoration.setFocusedItem(MultiselectPart.Message(item.conversationMessage))
binding.conversationItemRecycler.invalidateItemDecorations()
binding.reactionsShade.visibility = View.VISIBLE
binding.conversationItemRecycler.suppressLayout(true)
if (itemView is ConversationItem) {
val audioUri = messageRecord.getAudioUriForLongClick()
if (audioUri != null) {
getVoiceNoteMediaController().pausePlayback(audioUri)
}
val childAdapterPosition = binding.conversationItemRecycler.getChildAdapterPosition(itemView)
var mp4Holder: GiphyMp4ProjectionPlayerHolder? = null
var videoBitmap: Bitmap? = null
if (childAdapterPosition != RecyclerView.NO_POSITION) {
mp4Holder = giphyMp4ProjectionRecycler.getCurrentHolder(childAdapterPosition)
if (mp4Holder?.isVisible == true) {
mp4Holder.pause()
videoBitmap = mp4Holder.bitmap
mp4Holder.hide()
}
}
val snapshot = ConversationItemSelection.snapshotView(itemView, binding.conversationItemRecycler, messageRecord, videoBitmap)
// TODO [alex] -- Should only have a focused view if the keyboard was open.
val focusedView = null // itemView.rootView.findFocus()
val bodyBubble = itemView.bodyBubble!!
val selectedConversationModel = SelectedConversationModel(
snapshot,
itemView.x,
itemView.y + binding.conversationItemRecycler.translationY,
bodyBubble.x,
bodyBubble.y,
bodyBubble.width,
audioUri,
messageRecord.isOutgoing,
focusedView
)
bodyBubble.visibility = View.INVISIBLE
itemView.reactionsView?.visibility = View.INVISIBLE
val quotedIndicatorVisible = itemView.quotedIndicator?.visibility == View.VISIBLE
if (quotedIndicatorVisible) {
ViewUtil.fadeOut(itemView.quotedIndicator!!, 150, View.INVISIBLE)
}
ViewUtil.hideKeyboard(requireContext(), itemView)
val showScrollButtons = viewModel.showScrollButtonsSnapshot
if (showScrollButtons) {
viewModel.setShowScrollButtons(false)
}
val conversationItem: ConversationItem = itemView
val isAttachmentKeyboardOpen = false /* TODO [alex] -- isAttachmentKeyboardOpen */
handleReaction(
item.conversationMessage,
ReactionsToolbarListener(item.conversationMessage),
selectedConversationModel,
object : OnHideListener {
override fun startHide() {
multiselectItemDecoration.hideShade(binding.conversationItemRecycler)
ViewUtil.fadeOut(binding.reactionsShade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
}
override fun onHide() {
binding.conversationItemRecycler.suppressLayout(false)
if (selectedConversationModel.audioUri != null) {
getVoiceNoteMediaController().resumePlayback(selectedConversationModel.audioUri, messageRecord.getId())
}
if (activity != null) {
WindowUtil.setLightStatusBarFromTheme(requireActivity())
WindowUtil.setLightNavigationBarFromTheme(requireActivity())
}
clearFocusedItem()
if (mp4Holder != null) {
mp4Holder.show()
mp4Holder.resume()
}
bodyBubble.visibility = View.VISIBLE
conversationItem.reactionsView?.visibility = View.VISIBLE
if (quotedIndicatorVisible && conversationItem.quotedIndicator != null) {
ViewUtil.fadeIn(conversationItem.quotedIndicator!!, 150)
}
if (showScrollButtons) {
viewModel.setShowScrollButtons(true)
}
if (isAttachmentKeyboardOpen) {
// listener.openAttachmentKeyboard();
}
}
}
)
} else {
clearFocusedItem()
adapter.toggleSelection(item)
binding.conversationItemRecycler.invalidateItemDecorations()
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(actionModeCallback)
}
}
}
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) {
GroupDescriptionDialog.show(childFragmentManager, groupName, description, shouldLinkifyWebLinks)
}
private fun MessageRecord.getAudioUriForLongClick(): Uri? {
val playbackState = getVoiceNoteMediaController().voiceNotePlaybackState.value
if (playbackState == null || !playbackState.isPlaying) {
return null
}
if (hasAudio() || !isMms) {
return null
}
val uri = (this as MmsMessageRecord).slideDeck.audioSlide?.uri
return uri.takeIf { it == playbackState.uri }
}
}
private inner class ConversationOptionsMenuCallback : ConversationOptionsMenu.Callback {
@ -1182,7 +1606,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
hasActiveGroupCall = groupCallViewModel.hasActiveGroupCallSnapshot,
distributionType = args.distributionType,
threadId = args.threadId,
isInMessageRequest = false, // TODO [alex]
isInMessageRequest = viewModel.hasMessageRequestState,
isInBubble = args.conversationScreenType.isInBubble
)
}
@ -1276,6 +1700,61 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
}
private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener {
override fun onReactionSelected(messageRecord: MessageRecord, emoji: String?) {
reactionDelegate.hide()
}
override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) {
reactionDelegate.hide()
}
}
private inner class MotionEventRelayDrain : MotionEventRelay.Drain {
override fun accept(motionEvent: MotionEvent): Boolean {
return reactionDelegate.applyTouchEvent(motionEvent)
}
}
private inner class ReactionsToolbarListener(
private val conversationMessage: ConversationMessage
) : OnActionSelectedListener {
override fun onActionSelected(action: ConversationReactionOverlay.Action) {
when (action) {
ConversationReactionOverlay.Action.REPLY -> handleReplyToMessage(conversationMessage)
ConversationReactionOverlay.Action.EDIT -> handleEditMessage(conversationMessage)
ConversationReactionOverlay.Action.FORWARD -> handleForwardMessageParts(conversationMessage.multiselectCollection.toSet())
ConversationReactionOverlay.Action.RESEND -> Unit // TODO [cfv2]
ConversationReactionOverlay.Action.DOWNLOAD -> handleSaveAttachment(conversationMessage.messageRecord as MediaMmsMessageRecord)
ConversationReactionOverlay.Action.COPY -> handleCopyMessage(conversationMessage.multiselectCollection.toSet())
ConversationReactionOverlay.Action.MULTISELECT -> Unit // TODO [cfv2]
ConversationReactionOverlay.Action.PAYMENT_DETAILS -> Unit // TODO [cfv2]
ConversationReactionOverlay.Action.VIEW_INFO -> handleDisplayDetails(conversationMessage)
ConversationReactionOverlay.Action.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet())
}
}
}
inner class ActionModeCallback : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = calculateSelectedItemCount()
// TODO [alex] listener.onMessageActionToolbarOpened();
setCorrectActionModeMenuVisibility()
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = false
override fun onDestroyActionMode(mode: ActionMode) {
adapter.clearSelection()
setBottomActionBarVisibility(false)
// TODO [alex] listener.onMessageActionToolbarClosed();
binding.conversationItemRecycler.invalidateItemDecorations()
actionMode = null
}
}
// endregion Conversation Callbacks
private class LastSeenPositionUpdater(

View file

@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.recipients.Recipient
@ -59,6 +60,8 @@ class ConversationViewModel(
val scrollButtonState: Flowable<ConversationScrollButtonState> = scrollButtonStateStore.stateFlowable
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
val showScrollButtonsSnapshot: Boolean
get() = scrollButtonStateStore.state.showScrollButtons
private val _recipient: BehaviorSubject<Recipient> = BehaviorSubject.create()
val recipient: Observable<Recipient> = _recipient
@ -83,6 +86,10 @@ class ConversationViewModel(
val inputReadyState: Observable<InputReadyState>
private val hasMessageRequestStateSubject: BehaviorSubject<Boolean> = BehaviorSubject.createDefault(false)
val hasMessageRequestState: Boolean
get() = hasMessageRequestStateSubject.value ?: false
init {
disposables += recipientRepository
.conversationRecipient
@ -146,6 +153,8 @@ class ConversationViewModel(
isClientExpired = SignalStore.misc().isClientDeprecated,
isUnauthorized = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication())
)
}.doOnNext {
hasMessageRequestStateSubject.onNext(it.messageRequestState != MessageRequestState.NONE)
}.observeOn(AndroidSchedulers.mainThread())
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.view.MotionEvent
import androidx.lifecycle.ViewModel
/**
* Allows an activity to notify the fragment of a stream of motion events.
*/
class MotionEventRelay : ViewModel() {
private var drain: Drain? = null
fun setDrain(drain: Drain?) {
this.drain = drain
}
fun offer(motionEvent: MotionEvent?): Boolean {
return motionEvent?.let { drain?.accept(it) } ?: false
}
interface Drain {
fun accept(motionEvent: MotionEvent): Boolean
}
}

View file

@ -100,6 +100,20 @@
</org.thoughtcrime.securesms.util.views.DarkOverflowToolbar>
<!-- TODO [alex] - Placeholder for banner container -->
<View
android:id="@+id/conversation_banner_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<FrameLayout
android:id="@+id/reactions_shade"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/reactions_screen_light_shade_color"
android:foreground="@color/reactions_screen_dark_shade_color"
android:visibility="gone" />
<org.thoughtcrime.securesms.components.ConversationScrollToView
android:id="@+id/scroll_to_mention"
android:layout_width="wrap_content"
@ -182,4 +196,26 @@
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@id/keyboard_guideline" />
<org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
android:id="@+id/conversation_bottom_action_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/navigation_bar_guideline"
app:layout_constraintEnd_toEndOf="@+id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@+id/parent_start_guideline" />
<ViewStub
android:id="@+id/conversation_reaction_scrubber_stub"
android:layout_width="0dp"
android:layout_height="0dp"
android:inflatedId="@+id/conversation_reaction_scrubber"
android:layout="@layout/conversation_reaction_scrubber"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@+id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@+id/status_bar_guideline" />
</org.thoughtcrime.securesms.components.InputAwareConstraintLayout>

View file

@ -24,7 +24,7 @@ dependencyResolutionManagement {
// Compose
alias('androidx-compose-bom').to('androidx.compose:compose-bom:2023.01.00')
alias('androidx-compose-material3').to('androidx.compose.material3', 'material3').withoutVersion()
alias('androidx-compose-material3').to('androidx.compose.material3', 'material3').withoutVersion();
alias('androidx-compose-ui-tooling-preview').to('androidx.compose.ui', 'ui-tooling-preview').withoutVersion()
alias('androidx-compose-ui-tooling-core').to('androidx.compose.ui', 'ui-tooling').withoutVersion()
alias('androidx-compose-rxjava3').to('androidx.compose.runtime:runtime-rxjava3:1.4.2')