session-android/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.java

347 lines
13 KiB
Java

package org.thoughtcrime.securesms.conversation.v2.messages;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.Group;
import androidx.core.content.ContextCompat;
import com.google.android.flexbox.FlexboxLayout;
import com.google.android.flexbox.JustifyContent;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.ThemeUtil;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.util.NumberUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import network.loki.messenger.R;
public class EmojiReactionsView extends LinearLayout implements View.OnTouchListener {
// Normally 6dp, but we have 1dp left+right margin on the pills themselves
private final int OUTER_MARGIN = ViewUtil.dpToPx(2);
private static final int DEFAULT_THRESHOLD = 5;
private List<ReactionRecord> records;
private long messageId;
private ViewGroup container;
private Group showLess;
private VisibleMessageViewDelegate delegate;
private Handler gestureHandler = new Handler(Looper.getMainLooper());
private Runnable pressCallback;
private Runnable longPressCallback;
private long onDownTimestamp = 0;
private static long longPressDurationThreshold = 250;
private static long maxDoubleTapInterval = 200;
private boolean extended = false;
public EmojiReactionsView(Context context) {
super(context);
init(null);
}
public EmojiReactionsView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.view_emoji_reactions, this);
this.container = findViewById(R.id.layout_emoji_container);
this.showLess = findViewById(R.id.group_show_less);
records = new ArrayList<>();
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0);
typedArray.recycle();
}
}
public void clear() {
this.records.clear();
container.removeAllViews();
}
public void setReactions(long messageId, @NonNull List<ReactionRecord> records, boolean outgoing, VisibleMessageViewDelegate delegate) {
this.delegate = delegate;
if (records.equals(this.records)) {
return;
}
FlexboxLayout containerLayout = (FlexboxLayout) this.container;
containerLayout.setJustifyContent(outgoing ? JustifyContent.FLEX_END : JustifyContent.FLEX_START);
this.records.clear();
this.records.addAll(records);
if (this.messageId != messageId) {
extended = false;
}
this.messageId = messageId;
displayReactions(extended ? Integer.MAX_VALUE : DEFAULT_THRESHOLD);
}
@Override
public boolean onTouch(View v, MotionEvent event) {
if (v.getTag() == null) return false;
Reaction reaction = (Reaction) v.getTag();
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) onDown(new MessageId(reaction.messageId, reaction.isMms));
else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback();
else if (action == MotionEvent.ACTION_UP) onUp(reaction);
return true;
}
private void displayReactions(int threshold) {
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
List<Reaction> reactions = buildSortedReactionsList(records, userPublicKey, threshold);
container.removeAllViews();
LinearLayout overflowContainer = new LinearLayout(getContext());
overflowContainer.setOrientation(LinearLayout.HORIZONTAL);
int innerPadding = ViewUtil.dpToPx(4);
overflowContainer.setPaddingRelative(innerPadding,innerPadding,innerPadding,innerPadding);
int pixelSize = ViewUtil.dpToPx(1);
for (Reaction reaction : reactions) {
if (container.getChildCount() + 1 >= DEFAULT_THRESHOLD && threshold != Integer.MAX_VALUE && reactions.size() > threshold) {
if (overflowContainer.getParent() == null) {
container.addView(overflowContainer);
MarginLayoutParams overflowParams = (MarginLayoutParams) overflowContainer.getLayoutParams();
overflowParams.height = ViewUtil.dpToPx(26);
overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize);
overflowContainer.setLayoutParams(overflowParams);
overflowContainer.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.reaction_pill_background));
}
View pill = buildPill(getContext(), this, reaction, true);
pill.setOnClickListener(v -> {
extended = true;
displayReactions(Integer.MAX_VALUE);
});
pill.findViewById(R.id.reactions_pill_count).setVisibility(View.GONE);
pill.findViewById(R.id.reactions_pill_spacer).setVisibility(View.GONE);
overflowContainer.addView(pill);
} else {
View pill = buildPill(getContext(), this, reaction, false);
pill.setTag(reaction);
pill.setOnTouchListener(this);
MarginLayoutParams params = (MarginLayoutParams) pill.getLayoutParams();
params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize);
pill.setLayoutParams(params);
container.addView(pill);
}
}
int overflowChildren = overflowContainer.getChildCount();
int negativeMargin = ViewUtil.dpToPx(-8);
for (int i = 0; i < overflowChildren; i++) {
View child = overflowContainer.getChildAt(i);
MarginLayoutParams childParams = (MarginLayoutParams) child.getLayoutParams();
if ((i == 0 && overflowChildren > 1) || i + 1 < overflowChildren) {
// if first and there is more than one child, or we are not the last child then set negative right margin
childParams.setMargins(0,0, negativeMargin, 0);
child.setLayoutParams(childParams);
}
}
if (threshold == Integer.MAX_VALUE) {
showLess.setVisibility(VISIBLE);
for (int id : showLess.getReferencedIds()) {
findViewById(id).setOnClickListener(view -> {
extended = false;
displayReactions(DEFAULT_THRESHOLD);
});
}
} else {
showLess.setVisibility(GONE);
}
}
private void onReactionClicked(Reaction reaction) {
if (reaction.messageId != 0) {
MessageId messageId = new MessageId(reaction.messageId, reaction.isMms);
delegate.onReactionClicked(reaction.emoji, messageId, reaction.userWasSender);
}
}
private static @NonNull List<Reaction> buildSortedReactionsList(@NonNull List<ReactionRecord> records, String userPublicKey, int threshold) {
Map<String, Reaction> counters = new LinkedHashMap<>();
for (ReactionRecord record : records) {
String baseEmoji = EmojiUtil.getCanonicalRepresentation(record.getEmoji());
Reaction info = counters.get(baseEmoji);
if (info == null) {
info = new Reaction(record.getMessageId(), record.isMms(), record.getEmoji(), record.getCount(), record.getSortId(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
} else {
info.update(record.getEmoji(), record.getCount(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
}
counters.put(baseEmoji, info);
}
List<Reaction> reactions = new ArrayList<>(counters.values());
Collections.sort(reactions, Collections.reverseOrder());
if (reactions.size() >= threshold + 2 && threshold != Integer.MAX_VALUE) {
List<Reaction> shortened = new ArrayList<>(threshold + 2);
shortened.addAll(reactions.subList(0, threshold + 2));
return shortened;
} else {
return reactions;
}
}
private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction, boolean isCompact) {
View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false);
EmojiImageView emojiView = root.findViewById(R.id.reactions_pill_emoji);
TextView countView = root.findViewById(R.id.reactions_pill_count);
View spacer = root.findViewById(R.id.reactions_pill_spacer);
if (isCompact) {
root.setPaddingRelative(1,1,1,1);
ViewGroup.LayoutParams layoutParams = root.getLayoutParams();
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
root.setLayoutParams(layoutParams);
}
if (reaction.emoji != null) {
emojiView.setImageEmoji(reaction.emoji);
if (reaction.count >= 1) {
countView.setText(NumberUtil.getFormattedNumber(reaction.count));
} else {
countView.setVisibility(GONE);
spacer.setVisibility(GONE);
}
} else {
emojiView.setVisibility(GONE);
spacer.setVisibility(GONE);
countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count));
}
if (reaction.userWasSender && !isCompact) {
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected));
countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor));
} else {
if (!isCompact) {
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background));
}
}
return root;
}
private void onDown(MessageId messageId) {
removeLongPressCallback();
Runnable newLongPressCallback = () -> {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
if (delegate != null) {
delegate.onReactionLongClicked(messageId);
}
};
this.longPressCallback = newLongPressCallback;
gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold);
onDownTimestamp = new Date().getTime();
}
private void removeLongPressCallback() {
if (longPressCallback != null) {
gestureHandler.removeCallbacks(longPressCallback);
}
}
private void onUp(Reaction reaction) {
if ((new Date().getTime() - onDownTimestamp) < longPressDurationThreshold) {
removeLongPressCallback();
if (pressCallback != null) {
gestureHandler.removeCallbacks(pressCallback);
this.pressCallback = null;
} else {
Runnable newPressCallback = () -> {
onReactionClicked(reaction);
pressCallback = null;
};
this.pressCallback = newPressCallback;
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval);
}
}
}
private static class Reaction implements Comparable<Reaction> {
private final long messageId;
private final boolean isMms;
private String emoji;
private long count;
private long sortIndex;
private long lastSeen;
private boolean userWasSender;
Reaction(long messageId, boolean isMms, @Nullable String emoji, long count, long sortIndex, long lastSeen, boolean userWasSender) {
this.messageId = messageId;
this.isMms = isMms;
this.emoji = emoji;
this.count = count;
this.sortIndex = sortIndex;
this.lastSeen = lastSeen;
this.userWasSender = userWasSender;
}
void update(@NonNull String emoji, long count, long lastSeen, boolean userWasSender) {
if (!this.userWasSender) {
if (userWasSender || lastSeen > this.lastSeen) {
this.emoji = emoji;
}
}
this.count = this.count + count;
this.lastSeen = Math.max(this.lastSeen, lastSeen);
this.userWasSender = this.userWasSender || userWasSender;
}
@NonNull Reaction merge(@NonNull Reaction other) {
this.count = this.count + other.count;
this.lastSeen = Math.max(this.lastSeen, other.lastSeen);
this.userWasSender = this.userWasSender || other.userWasSender;
return this;
}
@Override
public int compareTo(Reaction rhs) {
Reaction lhs = this;
if (lhs.count == rhs.count ) {
return Long.compare(lhs.sortIndex, rhs.sortIndex);
} else {
return Long.compare(lhs.count, rhs.count);
}
}
}
}