Add "Tap to remove" option for emoji in ReactionsBottomSheet.

This commit is contained in:
Sagar
2025-03-25 20:06:19 +05:30
committed by Cody Henthorne
parent bcc11b9fbc
commit e88db06c8b
10 changed files with 185 additions and 61 deletions
@@ -20,7 +20,12 @@ import java.util.List;
final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecipientsAdapter.ViewHolder> {
private List<ReactionDetails> data = Collections.emptyList();
private ReactionViewPagerAdapter.EventListener listener = null;
private List<ReactionDetails> data = Collections.emptyList();
void setListener(ReactionViewPagerAdapter.EventListener listener) {
this.listener = listener;
}
public void updateData(List<ReactionDetails> newData) {
data = newData;
@@ -37,7 +42,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(data.get(position));
holder.bind(data.get(position), listener);
}
@Override
@@ -51,17 +56,19 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
private final BadgeImageView badge;
private final TextView recipient;
private final TextView emoji;
private final TextView tapToRemoveText;
public ViewHolder(@NonNull View itemView) {
super(itemView);
avatar = itemView.findViewById(R.id.reactions_bottom_view_recipient_avatar);
badge = itemView.findViewById(R.id.reactions_bottom_view_recipient_badge);
recipient = itemView.findViewById(R.id.reactions_bottom_view_recipient_name);
emoji = itemView.findViewById(R.id.reactions_bottom_view_recipient_emoji);
avatar = itemView.findViewById(R.id.reactions_bottom_view_recipient_avatar);
badge = itemView.findViewById(R.id.reactions_bottom_view_recipient_badge);
recipient = itemView.findViewById(R.id.reactions_bottom_view_recipient_name);
emoji = itemView.findViewById(R.id.reactions_bottom_view_recipient_emoji);
tapToRemoveText = itemView.findViewById(R.id.reactions_bottom_view_recipient_tap_to_remove_action_text);
}
void bind(@NonNull ReactionDetails reaction) {
void bind(@NonNull ReactionDetails reaction, ReactionViewPagerAdapter.EventListener listener) {
this.emoji.setText(reaction.getDisplayEmoji());
if (reaction.getSender().isSelf()) {
@@ -69,10 +76,14 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
this.avatar.setAvatar(Glide.with(avatar), null, false);
this.badge.setBadge(null);
AvatarUtil.loadIconIntoImageView(reaction.getSender(), avatar);
itemView.setOnClickListener((view) -> 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);
}
}
}
@@ -18,10 +18,12 @@ import java.util.List;
*/
class ReactionViewPagerAdapter extends ListAdapter<EmojiCount, ReactionViewPagerAdapter.ViewHolder> {
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<EmojiCount, ReactionViewPager
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.onBind(getItem(position));
holder.onBind(getItem(position),listener);
holder.setSelected(selectedPosition);
}
@@ -80,12 +82,17 @@ class ReactionViewPagerAdapter extends ListAdapter<EmojiCount, ReactionViewPager
recycler.setAdapter(adapter);
}
public void onBind(@NonNull EmojiCount emojiCount) {
public void onBind(@NonNull EmojiCount emojiCount, EventListener listener) {
adapter.updateData(emojiCount.getReactions());
adapter.setListener(listener);
}
public void setSelected(int position) {
recycler.setNestedScrollingEnabled(getAdapterPosition() == position);
}
}
interface EventListener {
void onClick();
}
}
@@ -29,15 +29,17 @@ import org.thoughtcrime.securesms.util.WindowUtil;
import java.util.Objects;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogFragment {
private static final String ARGS_MESSAGE_ID = "reactions.args.message.id";
private static final String ARGS_IS_MMS = "reactions.args.is.mms";
private ViewPager2 recipientPagerView;
private ReactionViewPagerAdapter recipientsAdapter;
private ReactionsViewModel viewModel;
private Callback callback;
private ViewPager2 recipientPagerView;
private ReactionViewPagerAdapter recipientsAdapter;
private ReactionsViewModel viewModel;
private Callback callback;
private final LifecycleDisposable disposables = new LifecycleDisposable();
@@ -97,11 +99,11 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
disposables.bindTo(getViewLifecycleOwner());
setUpRecipientsRecyclerView();
setUpTabMediator(view, savedInstanceState);
MessageId messageId = new MessageId(requireArguments().getLong(ARGS_MESSAGE_ID));
setUpViewModel(messageId);
setUpRecipientsRecyclerView();
setUpTabMediator(view, savedInstanceState);
}
@Override
@@ -142,14 +144,12 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
}
private void setUpRecipientsRecyclerView() {
recipientsAdapter = new ReactionViewPagerAdapter();
recipientsAdapter = new ReactionViewPagerAdapter(() -> 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);
@@ -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
)
}
}
}
@@ -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<List<EmojiCount>> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new ReactionsViewModel(messageId));
return modelClass.cast(new ReactionsViewModel(reactionsRepository, messageId));
}
}
}
@@ -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() {}
}
}
@@ -42,10 +42,12 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel {
private final BehaviorSubject<EmojiSearchResult> searchResults;
private final BehaviorSubject<String> 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<List<ReactWithAnyEmojiPage>> emojiPages = new ReactionsRepository().getReactions(new MessageId(messageId))
.map(repository::getEmojiPageModels);
Observable<List<ReactWithAnyEmojiPage>> emojiPages = reactionsRepository.getReactions(new MessageId(messageId))
.map(repository::getEmojiPageModels);
Observable<MappingModelList> 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 extends ViewModel> T create(@NonNull Class<T> 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())));
}
}
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="52dp">
android:layout_height="52dp"
tools:viewBindingIgnore="true">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/reactions_bottom_view_recipient_avatar"
@@ -25,23 +25,44 @@
app:layout_constraintStart_toStartOf="@id/reactions_bottom_view_recipient_avatar"
app:layout_constraintTop_toTopOf="@id/reactions_bottom_view_recipient_avatar" />
<org.thoughtcrime.securesms.components.FromTextView
android:id="@+id/reactions_bottom_view_recipient_name"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
android:textColor="@color/signal_text_primary"
android:orientation="vertical"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reactions_bottom_view_recipient_emoji"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@id/reactions_bottom_view_recipient_avatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />
app:layout_constraintTop_toTopOf="parent">
<org.thoughtcrime.securesms.components.FromTextView
android:id="@+id/reactions_bottom_view_recipient_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
android:textColor="@color/signal_text_primary"
tools:text="@tools:sample/full_names" />
<org.thoughtcrime.securesms.components.FromTextView
android:id="@+id/reactions_bottom_view_recipient_tap_to_remove_action_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/ReactionsBottomSheetDialogFragment_tap_to_remove"
android:textAppearance="@style/TextAppearance.Signal.Caption"
android:textColor="@color/signal_text_secondary"
android:visibility="gone"
tools:text="Tap to remove"
tools:visibility="visible" />
</LinearLayout>
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/reactions_bottom_view_recipient_emoji"
+1
View File
@@ -2269,6 +2269,7 @@
<!-- ReactionsBottomSheetDialogFragment -->
<string name="ReactionsBottomSheetDialogFragment_all">All &#183; %1$d</string>
<string name="ReactionsBottomSheetDialogFragment_tap_to_remove">Tap to remove</string>
<!-- ReactionsConversationView -->
<string name="ReactionsConversationView_plus">+%1$d</string>
@@ -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<ReactionsRepository>()
@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()) }
}
}