diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index b2e89864ad..e00c19e46b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java index 73bd43c574..85ed14a257 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java @@ -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 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 overlayStub) { + public ConversationReactionDelegate(@NonNull Stub 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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 522feb420c..3489cd621d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index 5123060dee..0cad9e1cbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -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 selectedParts, - boolean shouldShowMessageRequest, - boolean isNonAdminInAnnouncementGroup) + public static MenuState getMenuState(@NonNull Recipient conversationRecipient, + @NonNull Set selectedParts, + boolean shouldShowMessageRequest, + boolean isNonAdminInAnnouncementGroup) { Builder builder = new Builder(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt index 357bf35171..5ee995ec65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt @@ -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) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index 6625aa22a7..0cf35137d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -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(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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 0b8aad3f80..8fb653ed28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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(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() + + 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 { + 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) { + // TODO [alex] -- Not implemented yet. + } + + private fun handleSaveAttachment(record: MediaMmsMessageRecord) { + // TODO [alex] -- Not implemented yet. + } + + private fun handleCopyMessage(messageParts: Set) { + // TODO [alex] -- Not implemented yet. + } + + private fun handleDisplayDetails(conversationMessage: ConversationMessage) { + // TODO [alex] -- Not implemented yet. + } + + private fun handleDeleteMessages(messageParts: Set) { + // 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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 8e3f1527c2..4d5029f0bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -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 = scrollButtonStateStore.stateFlowable .distinctUntilChanged() .observeOn(AndroidSchedulers.mainThread()) + val showScrollButtonsSnapshot: Boolean + get() = scrollButtonStateStore.state.showScrollButtons private val _recipient: BehaviorSubject = BehaviorSubject.create() val recipient: Observable = _recipient @@ -83,6 +86,10 @@ class ConversationViewModel( val inputReadyState: Observable + private val hasMessageRequestStateSubject: BehaviorSubject = 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()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MotionEventRelay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MotionEventRelay.kt new file mode 100644 index 0000000000..e40fb41aa2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MotionEventRelay.kt @@ -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 + } +} diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index ad9ef2f098..3b89e4451b 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -100,6 +100,20 @@ + + + + + + + + + diff --git a/dependencies.gradle b/dependencies.gradle index 94db4f98b5..268c78fe89 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -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')