Update reactions UI.

This commit is contained in:
Greyson Parrelli
2020-01-31 15:10:59 -05:00
parent 1dd2a4e9c5
commit 73160d4d26
18 changed files with 339 additions and 277 deletions

View File

@@ -42,6 +42,7 @@ import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -97,6 +98,7 @@ import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.reactions.ReactionsConversationView;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
@@ -158,7 +160,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private AvatarImageView contactPhoto;
private AlertView alertView;
private ViewGroup container;
protected ViewGroup reactionsContainer;
protected ReactionsConversationView reactionsView;
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
@@ -171,7 +173,6 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private Stub<StickerView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private @Nullable EventListener eventListener;
private ConversationItemReactionBubbles conversationItemReactionBubbles;
private int defaultBubbleColor;
private int measureCalls;
@@ -226,9 +227,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
this.quoteView = findViewById(R.id.quote_view);
this.container = findViewById(R.id.container);
this.reply = findViewById(R.id.reply_icon);
this.reactionsContainer = findViewById(R.id.reactions_bubbles_container);
this.conversationItemReactionBubbles = new ConversationItemReactionBubbles(this.reactionsContainer);
this.reactionsView = findViewById(R.id.reactions_view);
setOnClickListener(new ClickListener(null));
@@ -906,8 +905,23 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
private void setReactions(@NonNull MessageRecord current) {
conversationItemReactionBubbles.setReactions(current.getReactions());
reactionsContainer.setOnClickListener(v -> {
if (current.getReactions().isEmpty()) {
reactionsView.clear();
return;
}
bodyBubble.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
setReactionsWithWidth(current);
bodyBubble.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
}
private void setReactionsWithWidth(@NonNull MessageRecord current) {
reactionsView.setReactions(current.getReactions(), bodyBubble.getWidth());
reactionsView.setOnClickListener(v -> {
if (eventListener == null) return;
eventListener.onReactionClicked(current.getId(), current.isMms());

View File

@@ -1,177 +0,0 @@
package org.thoughtcrime.securesms.conversation;
import android.graphics.drawable.Drawable;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Pair;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
final class ConversationItemReactionBubbles {
private final ViewGroup reactionsContainer;
private final EmojiImageView primaryEmojiReaction;
private final EmojiImageView secondaryEmojiReaction;
ConversationItemReactionBubbles(@NonNull ViewGroup reactionsContainer) {
this.reactionsContainer = reactionsContainer;
this.primaryEmojiReaction = reactionsContainer.findViewById(R.id.reactions_bubbles_primary);
this.secondaryEmojiReaction = reactionsContainer.findViewById(R.id.reactions_bubbles_secondary);
}
void setReactions(@NonNull List<ReactionRecord> reactions) {
if (reactions.size() == 0) {
hideAllReactions();
return;
}
final Collection<ReactionInfo> reactionInfos = getReactionInfos(reactions);
if (reactionInfos.size() == 1) {
displaySingleReaction(reactionInfos.iterator().next());
} else {
displayMultipleReactions(reactionInfos);
}
}
private static @NonNull Collection<ReactionInfo> getReactionInfos(@NonNull List<ReactionRecord> reactions) {
final Map<String, ReactionInfo> counters = new HashMap<>();
for (ReactionRecord reaction : reactions) {
ReactionInfo info = counters.get(reaction.getEmoji());
if (info == null) {
info = new ReactionInfo(reaction.getEmoji(),
1,
reaction.getDateReceived(),
Recipient.self().getId().equals(reaction.getAuthor()));
} else {
info = new ReactionInfo(reaction.getEmoji(),
info.count + 1,
Math.max(info.lastSeen, reaction.getDateReceived()),
info.userWasSender || Recipient.self().getId().equals(reaction.getAuthor()));
}
counters.put(reaction.getEmoji(), info);
}
return counters.values();
}
private void hideAllReactions() {
reactionsContainer.setVisibility(View.GONE);
}
private void displaySingleReaction(@NonNull ReactionInfo reactionInfo) {
reactionsContainer.setVisibility(View.VISIBLE);
primaryEmojiReaction.setVisibility(View.VISIBLE);
secondaryEmojiReaction.setVisibility(View.GONE);
primaryEmojiReaction.setImageEmoji(reactionInfo.emoji);
primaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(reactionInfo));
}
private void displayMultipleReactions(@NonNull Collection<ReactionInfo> reactionInfos) {
reactionsContainer.setVisibility(View.VISIBLE);
primaryEmojiReaction.setVisibility(View.VISIBLE);
secondaryEmojiReaction.setVisibility(View.VISIBLE);
Pair<ReactionInfo, ReactionInfo> primaryAndSecondaryReactions = getPrimaryAndSecondaryReactions(reactionInfos);
primaryEmojiReaction.setImageEmoji(primaryAndSecondaryReactions.first.emoji);
primaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(primaryAndSecondaryReactions.first));
secondaryEmojiReaction.setImageEmoji(primaryAndSecondaryReactions.second.emoji);
secondaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(primaryAndSecondaryReactions.second));
}
private Drawable getBackgroundDrawableForReactionBubble(@NonNull ReactionInfo reactionInfo) {
return ThemeUtil.getThemedDrawable(reactionsContainer.getContext(),
reactionInfo.userWasSender ? R.attr.reactions_sent_background : R.attr.reactions_recv_background);
}
private Pair<ReactionInfo, ReactionInfo> getPrimaryAndSecondaryReactions(@NonNull Collection<ReactionInfo> reactionInfos) {
ReactionInfo mostPopular = null;
ReactionInfo latestReaction = null;
ReactionInfo secondLatestReaction = null;
ReactionInfo ourReaction = null;
for (ReactionInfo current : reactionInfos) {
if (current.userWasSender) {
ourReaction = current;
}
if (mostPopular == null) {
mostPopular = current;
} else if (mostPopular.count < current.count) {
mostPopular = current;
}
if (latestReaction == null) {
latestReaction = current;
} else if (latestReaction.lastSeen < current.lastSeen) {
if (current.count == mostPopular.count) {
mostPopular = current;
}
secondLatestReaction = latestReaction;
latestReaction = current;
} else if (secondLatestReaction == null) {
secondLatestReaction = current;
}
}
if (mostPopular == null) {
throw new AssertionError("getPrimaryAndSecondaryReactions was called with an empty list.");
}
if (ourReaction != null && !mostPopular.equals(ourReaction)) {
return Pair.create(mostPopular, ourReaction);
} else {
return Pair.create(mostPopular, mostPopular.equals(latestReaction) ? secondLatestReaction : latestReaction);
}
}
private static class ReactionInfo {
private final String emoji;
private final int count;
private final long lastSeen;
private final boolean userWasSender;
private ReactionInfo(@NonNull String emoji, int count, long lastSeen, boolean userWasSender) {
this.emoji = emoji;
this.count = count;
this.lastSeen = lastSeen;
this.userWasSender = userWasSender;
}
@Override
public int hashCode() {
return Objects.hash(emoji, count, lastSeen, userWasSender);
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof ReactionInfo)) return false;
ReactionInfo other = (ReactionInfo) obj;
return other.emoji.equals(emoji) &&
other.count == count &&
other.lastSeen == lastSeen &&
other.userWasSender == userWasSender;
}
}
}

View File

@@ -33,7 +33,7 @@ final class ConversationSwipeAnimationHelper {
float progress = dx / TRIGGER_DX;
updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign);
updateReactionsTransition(conversationItem.reactionsContainer, dx, sign);
updateReactionsTransition(conversationItem.reactionsView, dx, sign);
updateReplyIconTransition(conversationItem.reply, dx, progress, sign);
updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign);
}

View File

@@ -4,6 +4,8 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Objects;
public class ReactionRecord {
private final String emoji;
private final RecipientId author;
@@ -36,4 +38,20 @@ public class ReactionRecord {
public long getDateReceived() {
return dateReceived;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ReactionRecord that = (ReactionRecord) o;
return dateSent == that.dateSent &&
dateReceived == that.dateReceived &&
Objects.equals(emoji, that.emoji) &&
Objects.equals(author, that.author);
}
@Override
public int hashCode() {
return Objects.hash(emoji, author, dateSent, dateReceived);
}
}

View File

@@ -49,7 +49,8 @@ public class MegaphoneRepository {
@MainThread
public void onFirstEverAppLaunch() {
executor.execute(() -> {
// Future megaphones we don't want to show to new users should get marked as finished here.
database.markFinished(Event.REACTIONS);
resetDatabaseCache();
});
}

View File

@@ -0,0 +1,198 @@
package org.thoughtcrime.securesms.reactions;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.LayoutInflater;
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 com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class ReactionsConversationView extends LinearLayout {
private static final int OUTER_MARGIN = ViewUtil.dpToPx(6);
private boolean outgoing;
private List<ReactionRecord> records;
public ReactionsConversationView(Context context) {
super(context);
init(null);
}
public ReactionsConversationView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
records = new ArrayList<>();
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ReactionsConversationView, 0, 0);
outgoing = typedArray.getBoolean(R.styleable.ReactionsConversationView_rcv_outgoing, false);
}
}
public void clear() {
removeAllViews();
}
public void setReactions(@NonNull List<ReactionRecord> records, int bubbleWidth) {
if (records.equals(this.records)) {
return;
}
this.records.clear();
this.records.addAll(records);
List<Reaction> reactions = buildSortedReactionsList(records);
removeAllViews();
for (Reaction reaction : reactions) {
addView(buildPill(getContext(), this, reaction));
}
measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
int railWidth = getMeasuredWidth();
if (railWidth < (bubbleWidth - OUTER_MARGIN)) {
int margin = (bubbleWidth - railWidth - OUTER_MARGIN);
if (outgoing) {
ViewUtil.setRightMargin(this, margin);
} else {
ViewUtil.setLeftMargin(this, margin);
}
} else {
if (outgoing) {
ViewUtil.setRightMargin(this, OUTER_MARGIN);
} else {
ViewUtil.setLeftMargin(this, OUTER_MARGIN);
}
}
}
private static @NonNull List<Reaction> buildSortedReactionsList(@NonNull List<ReactionRecord> records) {
Map<String, Reaction> counters = new LinkedHashMap<>();
RecipientId selfId = Recipient.self().getId();
for (ReactionRecord record : records) {
Reaction info = counters.get(record.getEmoji());
if (info == null) {
info = new Reaction(record.getEmoji(), 1, record.getDateReceived(), selfId.equals(record.getAuthor()));
} else {
info.update(record.getDateReceived(), selfId.equals(record.getAuthor()));
}
counters.put(record.getEmoji(), info);
}
List<Reaction> reactions = new ArrayList<>(counters.values());
Collections.sort(reactions, Collections.reverseOrder());
if (reactions.size() > 3) {
List<Reaction> shortened = new ArrayList<>(3);
shortened.add(reactions.get(0));
shortened.add(reactions.get(1));
shortened.add(Stream.of(reactions).skip(2).reduce(new Reaction(null, 0, 0, false), Reaction::merge));
return shortened;
} else {
return reactions;
}
}
private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction) {
View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false);
TextView 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 (reaction.emoji != null) {
emojiView.setText(reaction.emoji);
if (reaction.count > 1) {
countView.setText(String.valueOf(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) {
root.setBackground(ThemeUtil.getThemedDrawable(context, R.attr.reactions_pill_selected_background));
countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactions_pill_selected_text_color));
} else {
root.setBackground(ThemeUtil.getThemedDrawable(context, R.attr.reactions_pill_background));
}
return root;
}
private static class Reaction implements Comparable<Reaction> {
private String emoji;
private int count;
private long lastSeen;
private boolean userWasSender;
Reaction(@Nullable String emoji, int count, long lastSeen, boolean userWasSender) {
this.emoji = emoji;
this.count = count;
this.lastSeen = lastSeen;
this.userWasSender = userWasSender;
}
void update(long lastSeen, boolean userWasSender) {
this.count = this.count + 1;
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 Integer.compare(lhs.count, rhs.count);
}
return Long.compare(lhs.lastSeen, rhs.lastSeen);
}
}
}

View File

@@ -233,6 +233,16 @@ public class ViewUtil {
view.requestLayout();
}
public static void setRightMargin(@NonNull View view, int margin) {
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin;
} else {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;
}
view.forceLayout();
view.requestLayout();
}
public static void setTopMargin(@NonNull View view, int margin) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin = margin;
view.requestLayout();