diff --git a/res/drawable/reaction_pill_background.xml b/res/drawable/reaction_pill_background.xml new file mode 100644 index 0000000000..bad81d54c5 --- /dev/null +++ b/res/drawable/reaction_pill_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/drawable/reaction_pill_background_selected.xml b/res/drawable/reaction_pill_background_selected.xml new file mode 100644 index 0000000000..b52af9b3d2 --- /dev/null +++ b/res/drawable/reaction_pill_background_selected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml index 0cf64875f4..99863ad7c7 100644 --- a/res/layout/conversation_item_received.xml +++ b/res/layout/conversation_item_received.xml @@ -220,5 +220,15 @@ + + diff --git a/res/layout/conversation_item_sent.xml b/res/layout/conversation_item_sent.xml index 9cc41ebcb1..6937a1bbfe 100644 --- a/res/layout/conversation_item_sent.xml +++ b/res/layout/conversation_item_sent.xml @@ -197,6 +197,16 @@ + + + + + + + + + + + diff --git a/res/values-night/colors.xml b/res/values-night/colors.xml new file mode 100644 index 0000000000..9c1530a47e --- /dev/null +++ b/res/values-night/colors.xml @@ -0,0 +1,9 @@ + + + @color/black + #80414347 + @color/core_dark_05 + #ff282828 + @color/reaction_pill_border + @color/reaction_pill_text_color + diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 93e3cc8bd7..923f7b103a 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -250,6 +250,13 @@ + + + + + + + diff --git a/res/values/colors.xml b/res/values/colors.xml index ea62242605..9c2fb900a3 100644 --- a/res/values/colors.xml +++ b/res/values/colors.xml @@ -75,4 +75,11 @@ #ff999999 + + #ffffff + #80f1f1f1 + #545863 + @color/gray5 + @color/reaction_pill_border + @color/reaction_pill_text_color diff --git a/res/values/strings.xml b/res/values/strings.xml index f7ead36a3a..ab83e4e4b3 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -722,6 +722,8 @@ Symbols Flags + + +%1$d Delete Old Messages diff --git a/src/com/b44t/messenger/DcContext.java b/src/com/b44t/messenger/DcContext.java index caa7ca647b..e612f5046f 100644 --- a/src/com/b44t/messenger/DcContext.java +++ b/src/com/b44t/messenger/DcContext.java @@ -11,6 +11,7 @@ public class DcContext { public final static int DC_EVENT_ERROR = 400; public final static int DC_EVENT_ERROR_SELF_NOT_IN_GROUP = 410; public final static int DC_EVENT_MSGS_CHANGED = 2000; + public final static int DC_EVENT_REACTIONS_CHANGED = 2001; public final static int DC_EVENT_INCOMING_MSG = 2005; public final static int DC_EVENT_MSGS_NOTICED = 2008; public final static int DC_EVENT_MSG_DELIVERED = 2010; diff --git a/src/com/b44t/messenger/rpc/Reaction.java b/src/com/b44t/messenger/rpc/Reaction.java new file mode 100644 index 0000000000..493cb4ec50 --- /dev/null +++ b/src/com/b44t/messenger/rpc/Reaction.java @@ -0,0 +1,39 @@ +package com.b44t.messenger.rpc; + +import androidx.annotation.Nullable; + +public class Reaction { + // The reaction emoji string. + private final String emoji; + // The count of users that have reacted with this reaction. + private final int count; + // true if self-account reacted with this reaction, false otherwise. + private final boolean isFromSelf; + + public Reaction(String emoji, int count, boolean isFromSelf) { + this.emoji = emoji; + this.count = count; + this.isFromSelf = isFromSelf; + } + + public String getEmoji() { + return emoji; + } + + public int getCount() { + return count; + } + + public boolean isFromSelf() { + return isFromSelf; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj instanceof Reaction) { + Reaction reaction = (Reaction) obj; + return emoji.equals(reaction.getEmoji()) && count == reaction.getCount() && isFromSelf == reaction.isFromSelf(); + } + return false; + } +} diff --git a/src/com/b44t/messenger/rpc/Reactions.java b/src/com/b44t/messenger/rpc/Reactions.java new file mode 100644 index 0000000000..7b88498683 --- /dev/null +++ b/src/com/b44t/messenger/rpc/Reactions.java @@ -0,0 +1,26 @@ +package com.b44t.messenger.rpc; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Reactions { + // Map from a contact to it's reaction to message. + private final HashMap reactionsByContact; + // Unique reactions, sorted in descending order. + private final ArrayList reactions; + + public Reactions(HashMap reactionsByContact, ArrayList reactions) { + this.reactionsByContact = reactionsByContact; + this.reactions = reactions; + } + + public Map getReactionsByContact() { + return reactionsByContact; + } + + public List getReactions() { + return reactions; + } +} diff --git a/src/com/b44t/messenger/rpc/Rpc.java b/src/com/b44t/messenger/rpc/Rpc.java index 8f255c68c6..ab47023872 100644 --- a/src/com/b44t/messenger/rpc/Rpc.java +++ b/src/com/b44t/messenger/rpc/Rpc.java @@ -8,7 +8,6 @@ import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; @@ -41,7 +40,7 @@ private void processResponse() throws JsonSyntaxException { } else if (response.result != null) { future.set(response.result); } else { - future.setException(new RpcException("Got JSON-RPC response witout result or error: " + jsonResponse)); + future.setException(new RpcException("Got JSON-RPC response without result or error: " + jsonResponse)); } } @@ -100,9 +99,13 @@ public HttpResponse getHttpResponse(int accountId, String url) throws RpcExcepti return gson.fromJson(getResult("get_http_response", accountId, url), HttpResponse.class); } + public Reactions getMsgReactions(int accountId, int msgId) throws RpcException { + return gson.fromJson(getResult("get_message_reactions", accountId, msgId), Reactions.class); + } + private static class Request { - public String jsonrpc = "2.0"; + private final String jsonrpc = "2.0"; public String method; public Object[] params; public int id; @@ -115,7 +118,7 @@ public Request(String method, Object[] params, int id) { } private static class Response { - public int id = 0; + public int id; public JsonElement result; public JsonElement error; diff --git a/src/org/thoughtcrime/securesms/BaseConversationItem.java b/src/org/thoughtcrime/securesms/BaseConversationItem.java index f9fa5b2d75..f76d81bb5f 100644 --- a/src/org/thoughtcrime/securesms/BaseConversationItem.java +++ b/src/org/thoughtcrime/securesms/BaseConversationItem.java @@ -13,6 +13,7 @@ import com.b44t.messenger.DcChat; import com.b44t.messenger.DcContext; import com.b44t.messenger.DcMsg; +import com.b44t.messenger.rpc.Rpc; import org.thoughtcrime.securesms.connect.DcHelper; @@ -30,6 +31,7 @@ public abstract class BaseConversationItem extends LinearLayout protected final Context context; protected final DcContext dcContext; + protected final Rpc rpc; protected @NonNull Set batchSelected = new HashSet<>(); @@ -39,6 +41,7 @@ public BaseConversationItem(Context context, AttributeSet attrs) { super(context, attrs); this.context = context; this.dcContext = DcHelper.getContext(context); + this.rpc = DcHelper.getRpc(context); } protected void bind(@NonNull DcMsg messageRecord, @@ -92,7 +95,7 @@ public void onClick(View v) { } protected class ClickListener implements View.OnClickListener { - private OnClickListener parent; + private final OnClickListener parent; ClickListener(@Nullable OnClickListener parent) { this.parent = parent; diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java index f18c42c1f0..ebafcb8d94 100644 --- a/src/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationFragment.java @@ -119,6 +119,7 @@ public void onCreate(Bundle icicle) { DcEventCenter eventCenter = DcHelper.getEventCenter(getContext()); eventCenter.addObserver(DcContext.DC_EVENT_INCOMING_MSG, this); eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this); + eventCenter.addObserver(DcContext.DC_EVENT_REACTIONS_CHANGED, this); eventCenter.addObserver(DcContext.DC_EVENT_MSG_DELIVERED, this); eventCenter.addObserver(DcContext.DC_EVENT_MSG_FAILED, this); eventCenter.addObserver(DcContext.DC_EVENT_MSG_READ, this); @@ -972,6 +973,7 @@ public void handleEvent(@NonNull DcEvent event) { } break; + case DcContext.DC_EVENT_REACTIONS_CHANGED: case DcContext.DC_EVENT_INCOMING_MSG: case DcContext.DC_EVENT_MSG_DELIVERED: case DcContext.DC_EVENT_MSG_FAILED: diff --git a/src/org/thoughtcrime/securesms/ConversationItem.java b/src/org/thoughtcrime/securesms/ConversationItem.java index f49a9923ac..bb1320a86d 100644 --- a/src/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/org/thoughtcrime/securesms/ConversationItem.java @@ -39,6 +39,8 @@ import com.b44t.messenger.DcChat; import com.b44t.messenger.DcContact; import com.b44t.messenger.DcMsg; +import com.b44t.messenger.rpc.Reactions; +import com.b44t.messenger.rpc.RpcException; import org.thoughtcrime.securesms.audio.AudioSlidePlayer; import org.thoughtcrime.securesms.components.AudioView; @@ -59,6 +61,7 @@ import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.reactions.ReactionsConversationView; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.LongClickMovementMethod; import org.thoughtcrime.securesms.util.MediaUtil; @@ -97,6 +100,7 @@ public class ConversationItem extends BaseConversationItem @Nullable private QuoteView quoteView; private ConversationItemFooter footer; private ConversationItemFooter stickerFooter; + private ReactionsConversationView reactionsView; private TextView groupSender; private View groupSenderHolder; private AvatarImageView contactPhoto; @@ -134,6 +138,7 @@ protected void onFinishInflate() { this.bodyText = findViewById(R.id.conversation_item_body); this.footer = findViewById(R.id.conversation_item_footer); this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer); + this.reactionsView = findViewById(R.id.reactions_view); this.groupSender = findViewById(R.id.group_message_sender); this.contactPhoto = findViewById(R.id.contact_photo); this.contactPhotoHolder = findViewById(R.id.contact_photo_container); @@ -185,6 +190,7 @@ public void bind(@NonNull DcMsg messageRecord, setGroupMessageStatus(); setAuthor(messageRecord, showSender); setMessageSpacing(context); + setReactions(messageRecord); setFooter(messageRecord, locale); setQuote(messageRecord); } @@ -667,6 +673,19 @@ private void setFooter(@NonNull DcMsg current, @NonNull Locale locale) { activeFooter.setMessageRecord(current, locale); } + private void setReactions(@NonNull DcMsg current) { + try { + Reactions reactions = rpc.getMsgReactions(dcContext.getAccountId(), current.getId()); + if (reactions == null) { + reactionsView.clear(); + } else { + reactionsView.setReactions(reactions.getReactions()); + } + } catch (RpcException e) { + reactionsView.clear(); + } + } + private ConversationItemFooter getActiveFooter(@NonNull DcMsg messageRecord) { if (hasSticker(messageRecord)) { return stickerFooter; diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java b/src/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java new file mode 100644 index 0000000000..aa3651d625 --- /dev/null +++ b/src/org/thoughtcrime/securesms/reactions/ReactionsConversationView.java @@ -0,0 +1,124 @@ +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 androidx.core.content.ContextCompat; + +import com.b44t.messenger.rpc.Reaction; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiImageView; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.ArrayList; +import java.util.List; + +public class ReactionsConversationView extends LinearLayout { + + // Normally 6dp, but we have 1dp left+right margin on the pills themselves + private static final int OUTER_MARGIN = ViewUtil.dpToPx(5); + + private final List reactions = new ArrayList<>(); + private boolean isIncoming; + + 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) { + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ReactionsConversationView, 0, 0); + isIncoming = typedArray.getInt(R.styleable.ReactionsConversationView_reaction_type, 0) == 2; + } + } + + public void clear() { + this.reactions.clear(); + removeAllViews(); + } + + public void setReactions(List reactions) { + if (reactions.equals(this.reactions)) { + return; + } + + clear(); + this.reactions.addAll(reactions); + + for (Reaction reaction : buildShortenedReactionsList(this.reactions)) { + View pill = buildPill(getContext(), this, reaction); + addView(pill); + } + + if (isIncoming) { + ViewUtil.setLeftMargin(this, OUTER_MARGIN); + } else { + ViewUtil.setRightMargin(this, OUTER_MARGIN); + } + } + + private static @NonNull List buildShortenedReactionsList(@NonNull List reactions) { + if (reactions.size() > 3) { + List shortened = new ArrayList<>(3); + shortened.add(reactions.get(0)); + shortened.add(reactions.get(1)); + int count = 0; + boolean isFromSelf = false; + for (int index = 2; index < reactions.size(); index++) { + count += reactions.get(index).getCount(); + isFromSelf = isFromSelf || reactions.get(index).isFromSelf(); + } + shortened.add(new Reaction(null, count, isFromSelf)); + + 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); + 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 (reaction.getEmoji() != null) { + emojiView.setImageEmoji(reaction.getEmoji()); + + if (reaction.getCount() > 1) { + countView.setText(String.valueOf(reaction.getCount())); + } else { + countView.setVisibility(GONE); + spacer.setVisibility(GONE); + } + } else { + emojiView.setVisibility(GONE); + spacer.setVisibility(GONE); + countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.getCount())); + } + + if (reaction.isFromSelf()) { + root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)); + countView.setTextColor(ContextCompat.getColor(context, R.color.reaction_pill_text_color_selected)); + } else { + root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)); + } + + return root; + } +} diff --git a/src/org/thoughtcrime/securesms/util/ViewUtil.java b/src/org/thoughtcrime/securesms/util/ViewUtil.java index 22383668dc..7c032a5e68 100644 --- a/src/org/thoughtcrime/securesms/util/ViewUtil.java +++ b/src/org/thoughtcrime/securesms/util/ViewUtil.java @@ -254,6 +254,16 @@ public static void setLeftMargin(@NonNull View view, int margin) { 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();