From e88db06c8b821764d91e33f65550a006dd6e6212 Mon Sep 17 00:00:00 2001 From: Sagar <85388413+Sagar0-0@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:06:19 +0530 Subject: [PATCH] Add "Tap to remove" option for emoji in ReactionsBottomSheet. --- .../reactions/ReactionRecipientsAdapter.java | 25 +++++--- .../reactions/ReactionViewPagerAdapter.java | 15 +++-- .../ReactionsBottomSheetDialogFragment.java | 24 ++++---- .../reactions/ReactionsRepository.kt | 13 +++++ .../reactions/ReactionsViewModel.java | 19 ++++-- ...WithAnyEmojiBottomSheetDialogFragment.java | 20 ++++--- .../any/ReactWithAnyEmojiViewModel.java | 26 +++++---- ...m_sheet_dialog_fragment_recipient_item.xml | 45 ++++++++++---- app/src/main/res/values/strings.xml | 1 + .../reactions/ReactionsViewModelTest.kt | 58 +++++++++++++++++++ 10 files changed, 185 insertions(+), 61 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/reactions/ReactionsViewModelTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index d0ba563a2c..1495dbb75b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -20,7 +20,12 @@ import java.util.List; final class ReactionRecipientsAdapter extends RecyclerView.Adapter { - private List data = Collections.emptyList(); + private ReactionViewPagerAdapter.EventListener listener = null; + private List data = Collections.emptyList(); + + void setListener(ReactionViewPagerAdapter.EventListener listener) { + this.listener = listener; + } public void updateData(List newData) { data = newData; @@ -37,7 +42,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter listener.onClick()); + tapToRemoveText.setVisibility(View.VISIBLE); } else { this.recipient.setText(reaction.getSender().getDisplayName(itemView.getContext())); this.avatar.setAvatar(Glide.with(avatar), reaction.getSender(), false); this.badge.setBadgeFromRecipient(reaction.getSender()); + itemView.setOnClickListener(null); + tapToRemoveText.setVisibility(View.GONE); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java index ea8e62f5a4..fc5b49e59c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java @@ -18,10 +18,12 @@ import java.util.List; */ class ReactionViewPagerAdapter extends ListAdapter { - private int selectedPosition = 0; + private int selectedPosition = 0; + private final EventListener listener; - protected ReactionViewPagerAdapter() { + protected ReactionViewPagerAdapter(@NonNull EventListener listener) { super(new AlwaysChangedDiffUtil<>()); + this.listener = listener; } @NonNull EmojiCount getEmojiCount(int position) { @@ -50,7 +52,7 @@ class ReactionViewPagerAdapter extends ListAdapter viewModel.removeReactionEmoji()); recipientPagerView.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @Override public void onPageSelected(int position) { - recipientPagerView.post(() -> { - recipientsAdapter.enableNestedScrollingForPosition(position); - }); + recipientPagerView.post(() -> recipientsAdapter.enableNestedScrollingForPosition(position)); } @Override @@ -164,7 +164,7 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF } private void setUpViewModel(@NonNull MessageId messageId) { - ReactionsViewModel.Factory factory = new ReactionsViewModel.Factory(messageId); + ReactionsViewModel.Factory factory = new ReactionsViewModel.Factory(new ReactionsRepository(), messageId); viewModel = new ViewModelProvider(this, factory).get(ReactionsViewModel.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt index 2ca2f35787..e7182106ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.reactions import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.ObservableEmitter import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.components.emoji.EmojiUtil import org.thoughtcrime.securesms.database.DatabaseObserver import org.thoughtcrime.securesms.database.SignalDatabase @@ -10,6 +11,7 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.sms.MessageSender class ReactionsRepository { @@ -45,4 +47,15 @@ class ReactionsRepository { ) } } + + fun sendReactionRemoval(messageId: MessageId) { + val oldReactionRecord = SignalDatabase.reactions.getReactions(messageId).firstOrNull { it.author == Recipient.self().id } ?: return + SignalExecutors.BOUNDED.execute { + MessageSender.sendReactionRemoval( + AppDependencies.application.applicationContext, + MessageId(messageId.id), + oldReactionRecord + ) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java index 249c63251a..12c19e1b5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Map; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Observable; public class ReactionsViewModel extends ViewModel { @@ -20,9 +21,9 @@ public class ReactionsViewModel extends ViewModel { private final MessageId messageId; private final ReactionsRepository repository; - public ReactionsViewModel(@NonNull MessageId messageId) { + public ReactionsViewModel(@NonNull ReactionsRepository reactionRepository, @NonNull MessageId messageId) { this.messageId = messageId; - this.repository = new ReactionsRepository(); + this.repository = reactionRepository; } public @NonNull Observable> getEmojiCounts() { @@ -70,17 +71,23 @@ public class ReactionsViewModel extends ViewModel { return reactions.get(reactions.size() - 1).getDisplayEmoji(); } + void removeReactionEmoji() { + repository.sendReactionRemoval(messageId); + } + static final class Factory implements ViewModelProvider.Factory { - private final MessageId messageId; + private final ReactionsRepository reactionsRepository; + private final MessageId messageId; - Factory(@NonNull MessageId messageId) { - this.messageId = messageId; + Factory(@NonNull ReactionsRepository reactionsRepository, @NonNull MessageId messageId) { + this.reactionsRepository = reactionsRepository; + this.messageId = messageId; } @Override public @NonNull T create(@NonNull Class modelClass) { - return modelClass.cast(new ReactionsViewModel(messageId)); + return modelClass.cast(new ReactionsViewModel(reactionsRepository, messageId)); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java index 80fc91342b..7dce87f8c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java @@ -25,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.bottomsheet.BottomSheetDialog; +import org.signal.core.util.concurrent.LifecycleDisposable; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment; import org.thoughtcrime.securesms.components.emoji.EmojiEventListener; @@ -34,8 +35,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.keyboard.KeyboardPageCategoryIconMappingModel; import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageCategoriesAdapter; import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView; +import org.thoughtcrime.securesms.reactions.ReactionsRepository; import org.thoughtcrime.securesms.reactions.edit.EditReactionsActivity; -import org.signal.core.util.concurrent.LifecycleDisposable; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel; @@ -48,7 +49,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends FixedRound EmojiPageViewGridAdapter.VariationSelectorListener { - public 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"; @@ -126,7 +127,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends FixedRound public static ReactWithAnyEmojiBottomSheetDialogFragment createForCallingReactions() { ReactWithAnyEmojiBottomSheetDialogFragment fragment = new ReactWithAnyEmojiBottomSheetDialogFragment(); - Bundle args = new Bundle(); + Bundle args = new Bundle(); args.putLong(ARG_MESSAGE_ID, -1); args.putBoolean(ARG_IS_MMS, false); @@ -254,9 +255,10 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends FixedRound } private void initializeViewModel() { - Bundle args = requireArguments(); - ReactWithAnyEmojiRepository repository = new ReactWithAnyEmojiRepository(requireContext(), args.getString(ARG_RECENT_KEY, REACTION_STORAGE_KEY)); - ReactWithAnyEmojiViewModel.Factory factory = new ReactWithAnyEmojiViewModel.Factory(repository, args.getLong(ARG_MESSAGE_ID), args.getBoolean(ARG_IS_MMS)); + Bundle args = requireArguments(); + ReactionsRepository reactionsRepository = new ReactionsRepository(); + ReactWithAnyEmojiRepository repository = new ReactWithAnyEmojiRepository(requireContext(), args.getString(ARG_RECENT_KEY, REACTION_STORAGE_KEY)); + ReactWithAnyEmojiViewModel.Factory factory = new ReactWithAnyEmojiViewModel.Factory(reactionsRepository, repository, args.getLong(ARG_MESSAGE_ID), args.getBoolean(ARG_IS_MMS)); viewModel = new ViewModelProvider(this, factory).get(ReactWithAnyEmojiViewModel.class); } @@ -273,7 +275,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends FixedRound } @Override - public void onVariationSelectorStateChanged(boolean open) { } + public void onVariationSelectorStateChanged(boolean open) {} public interface Callback { void onReactWithAnyEmojiDialogDismissed(); @@ -336,9 +338,9 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends FixedRound } @Override - public void onClicked() { } + public void onClicked() {} @Override - public void onFocusLost() { } + public void onFocusLost() {} } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiViewModel.java index cb95a97aa8..7d84d57aaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiViewModel.java @@ -42,10 +42,12 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel { private final BehaviorSubject searchResults; private final BehaviorSubject selectedKey; - private ReactWithAnyEmojiViewModel(@NonNull ReactWithAnyEmojiRepository repository, - long messageId, - boolean isMms, - @NonNull EmojiSearchRepository emojiSearchRepository) + private ReactWithAnyEmojiViewModel( + @NonNull ReactionsRepository reactionsRepository, + @NonNull ReactWithAnyEmojiRepository repository, + long messageId, + boolean isMms, + @NonNull EmojiSearchRepository emojiSearchRepository) { this.repository = repository; this.messageId = messageId; @@ -54,8 +56,8 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel { this.searchResults = BehaviorSubject.createDefault(new EmojiSearchResult()); this.selectedKey = BehaviorSubject.createDefault(getStartingKey()); - Observable> emojiPages = new ReactionsRepository().getReactions(new MessageId(messageId)) - .map(repository::getEmojiPageModels); + Observable> emojiPages = reactionsRepository.getReactions(new MessageId(messageId)) + .map(repository::getEmojiPageModels); Observable emojiList = emojiPages.map(pages -> { MappingModelList list = new MappingModelList(); @@ -154,20 +156,22 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel { static class Factory implements ViewModelProvider.Factory { + private final ReactionsRepository reactionsRepository; private final ReactWithAnyEmojiRepository repository; private final long messageId; private final boolean isMms; - Factory(@NonNull ReactWithAnyEmojiRepository repository, long messageId, boolean isMms) { - this.repository = repository; - this.messageId = messageId; - this.isMms = isMms; + Factory(@NonNull ReactionsRepository reactionsRepository, @NonNull ReactWithAnyEmojiRepository repository, long messageId, boolean isMms) { + this.reactionsRepository = reactionsRepository; + this.repository = repository; + this.messageId = messageId; + this.isMms = isMms; } @Override public @NonNull T create(@NonNull Class modelClass) { //noinspection ConstantConditions - return modelClass.cast(new ReactWithAnyEmojiViewModel(repository, messageId, isMms, new EmojiSearchRepository(AppDependencies.getApplication()))); + return modelClass.cast(new ReactWithAnyEmojiViewModel(reactionsRepository, repository, messageId, isMms, new EmojiSearchRepository(AppDependencies.getApplication()))); } } diff --git a/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml b/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml index 3bf0b92a84..f624ae21f4 100644 --- a/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml +++ b/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml @@ -1,10 +1,10 @@ + android:layout_height="52dp" + tools:viewBindingIgnore="true"> - + app:layout_constraintTop_toTopOf="parent"> + + + + + + All · %1$d + Tap to remove +%1$d diff --git a/app/src/test/java/org/thoughtcrime/securesms/reactions/ReactionsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/reactions/ReactionsViewModelTest.kt new file mode 100644 index 0000000000..56bb4867f2 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/reactions/ReactionsViewModelTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.reactions + +import android.app.Application +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.TestScheduler +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.database.model.MessageId + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class ReactionsViewModelTest { + + private val testScheduler = TestScheduler() + private val repository = mockk() + + @Before + fun setUp() { + RxJavaPlugins.setInitIoSchedulerHandler { testScheduler } + RxJavaPlugins.setIoSchedulerHandler { testScheduler } + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + } + + @Test + fun `Given a message Id, when I removeReactionEmoji, then I expect Success`() { + // GIVEN + val messageId = MessageId(0) + val testSubject = ReactionsViewModel( + repository, + messageId + ) + every { repository.sendReactionRemoval(any()) } just runs + + // WHEN + testSubject.removeReactionEmoji() + + // THEN + verify(exactly = 1) { repository.sendReactionRemoval(any()) } + } +}