Show custom emoji reactions and allow sending them

Stephen Paul Weber created

Change summary

src/cheogram/java/com/cheogram/android/EmojiSearch.java             | 55 
src/main/java/eu/siacs/conversations/entities/Conversation.java     | 77 
src/main/java/eu/siacs/conversations/entities/Message.java          |  8 
src/main/java/eu/siacs/conversations/entities/Reaction.java         | 61 
src/main/java/eu/siacs/conversations/ui/BindingAdapters.java        | 41 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java | 83 
6 files changed, 246 insertions(+), 79 deletions(-)

Detailed changes

src/cheogram/java/com/cheogram/android/EmojiSearch.java 🔗

@@ -14,9 +14,13 @@ import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.ListAdapter;
 import androidx.recyclerview.widget.RecyclerView;
 
+import com.google.android.material.chip.Chip;
+import com.google.android.material.color.MaterialColors;
 import com.google.common.collect.Lists;
 import com.google.common.io.CharStreams;
 
+import io.ipfs.cid.Cid;
+
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.lang.Comparable;
@@ -24,6 +28,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.PriorityQueue;
 import java.util.Set;
 import java.util.TreeSet;
@@ -39,6 +44,7 @@ import org.json.JSONObject;
 
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.EmojiSearchRowBinding;
+import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor;
 
 public class EmojiSearch {
@@ -156,6 +162,19 @@ public class EmojiSearch {
 			return new SpannableStringBuilder(unicode);
 		}
 
+		public void setupChip(Chip chip, int count) {
+			if (count < 2) {
+				chip.setText(unicode);
+			} else {
+				chip.setText(String.format(Locale.ENGLISH, "%s %d", unicode, count));
+			}
+		}
+
+		@Override
+		public String toString() {
+			return unicode;
+		}
+
 		public String uniquePart() {
 			return unicode;
 		}
@@ -173,10 +192,15 @@ public class EmojiSearch {
 
 			return uniquePart().equals(((Emoji) o).uniquePart());
 		}
+
+		@Override
+		public int hashCode() {
+			return uniquePart().hashCode();
+		}
 	}
 
 	public static class CustomEmoji extends Emoji {
-		protected final String source;
+		public final String source;
 		protected final Drawable icon;
 
 		public CustomEmoji(final String shortcode, final String source, final Drawable icon, final String tag) {
@@ -185,21 +209,40 @@ public class EmojiSearch {
 			if (tag != null) tags.add(tag);
 			this.source = source;
 			this.icon = icon;
-			if (icon == null) {
-				throw new IllegalArgumentException("icon must not be null");
-			}
 		}
 
 		public SpannableStringBuilder toInsert() {
-			SpannableStringBuilder builder = new SpannableStringBuilder(":" + shortcodes.get(0) + ":");
-			builder.setSpan(new InlineImageSpan(icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+			SpannableStringBuilder builder = new SpannableStringBuilder(toString());
+			if (icon != null) builder.setSpan(new InlineImageSpan(icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
 			return builder;
 		}
 
+		public void setupChip(Chip chip, int count) {
+			if (icon == null) {
+				chip.setChipIconResource(R.drawable.ic_photo_24dp);
+				chip.setChipIconTint(
+						MaterialColors.getColorStateListOrNull(
+								chip.getContext(),
+								com.google.android.material.R.attr.colorOnSurface));
+			} else {
+				SpannableStringBuilder builder = new SpannableStringBuilder("😇"); // needs to be same size as an emoji
+				if (icon != null) builder.setSpan(new InlineImageSpan(icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+				chip.setText(builder); // We cannot use icon because it is a hardware bitmap
+			}
+			if (count > 1) {
+				chip.append(String.format(Locale.ENGLISH, " %d", count));
+			}
+		}
+
 		@Override
 		public String uniquePart() {
 			return source;
 		}
+
+		@Override
+		public String toString() {
+			return ":" + shortcodes.get(0) + ":";
+		}
 	}
 
 	public class EmojiSearchAdapter extends ListAdapter<Emoji, EmojiSearchAdapter.ViewHolder> {

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -23,6 +23,7 @@ import android.text.StaticLayout;
 import android.text.TextPaint;
 import android.text.TextUtils;
 import android.text.TextWatcher;
+import android.text.style.ImageSpan;
 import android.view.LayoutInflater;
 import android.view.MotionEvent;
 import android.view.Gravity;
@@ -64,7 +65,9 @@ import androidx.viewpager.widget.ViewPager;
 
 import com.caverock.androidsvg.SVG;
 
+import com.cheogram.android.BobTransfer;
 import com.cheogram.android.ConversationPage;
+import com.cheogram.android.GetThumbnailForCid;
 import com.cheogram.android.Util;
 import com.cheogram.android.WebxdcPage;
 
@@ -106,6 +109,7 @@ import java.util.stream.Collectors;
 import java.util.Set;
 import java.util.Timer;
 import java.util.TimerTask;
+import java.util.function.Function;
 
 import me.saket.bettermovementmethod.BetterLinkMovementMethod;
 
@@ -293,6 +297,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             for (int i = messages.size() - 1; i >= 0; --i) {
                 final Message message = messages.get(i);
                 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
+                if (asReaction(message) != null) continue;
                 if (message.isRead()) {
                     return first;
                 } else {
@@ -308,6 +313,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         synchronized (this.messages) {
             for (final Message message : Lists.reverse(this.messages)) {
                 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
+                if (asReaction(message) != null) continue;
                 if (message.getStatus() == Message.STATUS_RECEIVED) {
                     final String serverMsgId = message.getServerMsgId();
                     if (serverMsgId != null && multi) {
@@ -708,26 +714,59 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                     thread.first = m;
                 }
             }
-            final var reply = m.getReply();
-            if (reply != null && reply.getAttribute("id") != null) {
-                extraIds.add(reply.getAttribute("id"));
-                final var body = m.getBody(true).toString().replaceAll("\\s", "");
-                if (Emoticons.isEmoji(body)) {
-                    reactions.put(reply.getAttribute("id"), new Reaction(body, true, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId()));
-                    iterator.remove();
-                }
+            final var asReaction = asReaction(m);
+            if (asReaction != null) {
+                reactions.put(asReaction.first, asReaction.second);
+                iterator.remove();
             }
 
             if (m.wasMergedIntoPrevious(xmppConnectionService) || (m.getSubject() != null && !m.isOOb() && (m.getRawBody() == null || m.getRawBody().length() == 0)) || (getLockThread() && !extraIds.contains(m.replyId()) && (mthread == null || !mthread.getContent().equals(getThread() == null ? "" : getThread().getContent())))) {
                 iterator.remove();
             } else if (getLockThread() && mthread != null) {
+                final var reply = m.getReply();
+                if (reply != null && reply.getAttribute("id") != null) extraIds.add(reply.getAttribute("id"));
                 Element reactions = m.getReactionsEl();
                 if (reactions != null && reactions.getAttribute("id") != null) extraIds.add(reactions.getAttribute("id"));
             }
         }
     }
 
-    public Reaction.Aggregated aggregatedReactionsFor(Message m) {
+    protected Pair<String, Reaction> asReaction(Message m) {
+        final var reply = m.getReply();
+        if (reply != null && reply.getAttribute("id") != null) {
+            final var body = m.getBody(true).toString().replaceAll("\\s", "");
+            if (Emoticons.isEmoji(body)) {
+                return new Pair<>(reply.getAttribute("id"), new Reaction(body, null, m.getStatus() <= Message.STATUS_RECEIVED, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId()));
+            } else {
+                final var html = m.getHtml();
+                if (html == null) return null;
+
+                SpannableStringBuilder spannable = m.getSpannableBody(null, null, false);
+                ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
+                for (ImageSpan span : imageSpans) {
+                    final int start = spannable.getSpanStart(span);
+                    final int end = spannable.getSpanEnd(span);
+                    spannable.delete(start, end);
+                }
+                if (imageSpans.length == 1 && spannable.toString().replaceAll("\\s", "").length() < 1) {
+                    // Only one inline image, so it's a custom emoji by itself as a reply/reaction
+                    final var source = imageSpans[0].getSource();
+                    var shortcode = "";
+                    final var img = html.findChild("img");
+                    if (img != null) {
+                        shortcode = img.getAttribute("alt").replaceAll("(^:)|(:$)", "");
+                    }
+                    if (source != null && source.length() > 0 && source.substring(0, 4).equals("cid:")) {
+                        final Cid cid = BobTransfer.cid(Uri.parse(source));
+                        return new Pair<>(reply.getAttribute("id"), new Reaction(shortcode, cid, m.getStatus() <= Message.STATUS_RECEIVED, m.getCounterpart(), m.getTrueCounterpart(), m.getOccupantId()));
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    public Reaction.Aggregated aggregatedReactionsFor(Message m, Function<Reaction, GetThumbnailForCid> thumbnailer) {
         Set<Reaction> result = new HashSet<>();
         if (getMode() == MODE_MULTI) {
             result.addAll(reactions.get(m.getServerMsgId()));
@@ -737,7 +776,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             result.addAll(reactions.get(m.getRemoteMsgId()));
         }
         result.addAll(m.getReactions());
-        return Reaction.aggregated(result);
+        return Reaction.aggregated(result, thumbnailer);
     }
 
     public Thread getThread(String id) {
@@ -899,15 +938,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
 
     public Message getLatestMessage() {
         synchronized (this.messages) {
-            if (this.messages.size() == 0) {
-                Message message = new Message(this, "", Message.ENCRYPTION_NONE);
-                message.setType(Message.TYPE_STATUS);
-                message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
-                message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
+            for(final Message message : Lists.reverse(this.messages)) {
+                if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
+                if (asReaction(message) != null) continue;
                 return message;
-            } else {
-                return this.messages.get(this.messages.size() - 1);
             }
+
+            Message message = new Message(this, "", Message.ENCRYPTION_NONE);
+            message.setType(Message.TYPE_STATUS);
+            message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
+            message.setTimeReceived(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
+            return message;
         }
     }
 
@@ -1369,6 +1410,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             int count = 0;
             for(final Message message : Lists.reverse(this.messages)) {
                 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
+                if (asReaction(message) != null) continue;
                 final boolean muted = xmppConnectionService != null && message.getStatus() == Message.STATUS_RECEIVED && getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, getJid(), message.getOccupantId(), null, null));
                 if (muted) continue;
                 if (message.isRead()) {
@@ -1388,6 +1430,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         synchronized (this.messages) {
             for (Message message : messages) {
                 if (message.getSubject() != null && !message.isOOb() && (message.getRawBody() == null || message.getRawBody().length() == 0)) continue;
+                if (asReaction(message) != null) continue;
                 if (message.getStatus() == Message.STATUS_RECEIVED) {
                     ++count;
                 }

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -1084,10 +1084,14 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
+        return getSpannableBody(thumbnailer, fallbackImg, true);
+    }
+
+    public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg, final boolean includeReplyTo) {
         SpannableStringBuilder spannableBody;
         final Element html = getHtml();
         if (html == null || Build.VERSION.SDK_INT < 24) {
-            spannableBody = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody(getInReplyTo() != null)).trim());
+            spannableBody = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody(includeReplyTo && getInReplyTo() != null)).trim());
             spannableBody.setSpan(PLAIN_TEXT_SPAN, 0, spannableBody.length(), 0); // Let adapter know it can do more formatting
         } else {
             SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
@@ -1136,7 +1140,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             spannableBody = (SpannableStringBuilder) spannable.subSequence(0, i+1);
         }
 
-        if (getInReplyTo() != null && getModerated() == null) {
+        if (includeReplyTo && getInReplyTo() != null && getModerated() == null) {
             // Don't show quote if it's the message right before us
             if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody;
 

src/main/java/eu/siacs/conversations/entities/Reaction.java 🔗

@@ -2,6 +2,9 @@ package eu.siacs.conversations.entities;
 
 import androidx.annotation.NonNull;
 
+import com.cheogram.android.EmojiSearch;
+import com.cheogram.android.GetThumbnailForCid;
+
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
@@ -19,6 +22,8 @@ import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonToken;
 import com.google.gson.stream.JsonWriter;
 
+import io.ipfs.cid.Cid;
+
 import eu.siacs.conversations.xmpp.Jid;
 
 import java.io.IOException;
@@ -30,6 +35,7 @@ import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 
 public class Reaction {
 
@@ -45,7 +51,7 @@ public class Reaction {
     private static final Gson GSON;
 
     static {
-        GSON = new GsonBuilder().registerTypeAdapter(Jid.class, new JidTypeAdapter()).create();
+        GSON = new GsonBuilder().registerTypeAdapter(Jid.class, new JidTypeAdapter()).registerTypeAdapter(Cid.class, new CidTypeAdapter()).create();
     }
 
     public final String reaction;
@@ -53,14 +59,17 @@ public class Reaction {
     public final Jid from;
     public final Jid trueJid;
     public final String occupantId;
+    public final Cid cid;
 
     public Reaction(
             final String reaction,
+            final Cid cid,
             boolean received,
             final Jid from,
             final Jid trueJid,
             final String occupantId) {
         this.reaction = reaction;
+        this.cid = cid;
         this.received = received;
         this.from = from;
         this.trueJid = trueJid;
@@ -93,7 +102,7 @@ public class Reaction {
         builder.addAll(existing);
         builder.addAll(
                 Collections2.transform(
-                        reactions, r -> new Reaction(r, received, from, trueJid, occupantId)));
+                        reactions, r -> new Reaction(r, null, received, from, trueJid, occupantId)));
         return builder.build();
     }
 
@@ -108,7 +117,7 @@ public class Reaction {
         builder.addAll(Collections2.filter(existing, e -> !occupantId.equals(e.occupantId)));
         builder.addAll(
                 Collections2.transform(
-                        reactions, r -> new Reaction(r, received, from, trueJid, occupantId)));
+                        reactions, r -> new Reaction(r, null, received, from, trueJid, occupantId)));
         return builder.build();
     }
 
@@ -116,7 +125,8 @@ public class Reaction {
     @Override
     public String toString() {
         return MoreObjects.toStringHelper(this)
-                .add("reaction", reaction)
+                .add("reaction", cid == null ? reaction : null)
+                .add("cid", cid)
                 .add("received", received)
                 .add("from", from)
                 .add("trueJid", trueJid)
@@ -144,7 +154,7 @@ public class Reaction {
                 Collections2.filter(existing, e -> !from.asBareJid().equals(e.from.asBareJid())));
         builder.addAll(
                 Collections2.transform(
-                        reactions, r -> new Reaction(r, received, from, null, null)));
+                        reactions, r -> new Reaction(r, null, received, from, null, null)));
         return builder.build();
     }
 
@@ -171,31 +181,58 @@ public class Reaction {
         }
     }
 
+    private static class CidTypeAdapter extends TypeAdapter<Cid> {
+        @Override
+        public void write(final JsonWriter out, final Cid value) throws IOException {
+            if (value == null) {
+                out.nullValue();
+            } else {
+                out.value(value.toString());
+            }
+        }
+
+        @Override
+        public Cid read(final JsonReader in) throws IOException {
+            if (in.peek() == JsonToken.NULL) {
+                in.nextNull();
+                return null;
+            } else if (in.peek() == JsonToken.STRING) {
+                final String value = in.nextString();
+                return Cid.decode(value);
+            }
+            throw new IOException("Unexpected token");
+        }
+    }
+
     public static Aggregated aggregated(final Collection<Reaction> reactions) {
-        final Map<String, Integer> aggregatedReactions =
+        return aggregated(reactions, (r) -> null);
+    }
+
+    public static Aggregated aggregated(final Collection<Reaction> reactions, Function<Reaction, GetThumbnailForCid> thumbnailer) {
+        final Map<EmojiSearch.Emoji, Integer> aggregatedReactions =
                 Maps.transformValues(
-                        Multimaps.index(reactions, r -> r.reaction).asMap(), Collection::size);
-        final List<Map.Entry<String, Integer>> sortedList =
+                        Multimaps.index(reactions, r -> r.cid == null ? new EmojiSearch.Emoji(r.reaction, 0) : new EmojiSearch.CustomEmoji(r.reaction, r.cid.toString(), thumbnailer.apply(r).getThumbnail(r.cid), null)).asMap(), Collection::size);
+        final List<Map.Entry<EmojiSearch.Emoji, Integer>> sortedList =
                 Ordering.from(
                                 Comparator.comparingInt(
-                                        (Map.Entry<String, Integer> o) -> o.getValue()))
+                                        (Map.Entry<EmojiSearch.Emoji, Integer> o) -> o.getValue()))
                         .reverse()
                         .immutableSortedCopy(aggregatedReactions.entrySet());
         return new Aggregated(
                 sortedList,
                 ImmutableSet.copyOf(
                         Collections2.transform(
-                                Collections2.filter(reactions, r -> !r.received),
+                                Collections2.filter(reactions, r -> r.cid == null && !r.received),
                                 r -> r.reaction)));
     }
 
     public static final class Aggregated {
 
-        public final List<Map.Entry<String, Integer>> reactions;
+        public final List<Map.Entry<EmojiSearch.Emoji, Integer>> reactions;
         public final Set<String> ourReactions;
 
         private Aggregated(
-                final List<Map.Entry<String, Integer>> reactions, Set<String> ourReactions) {
+                final List<Map.Entry<EmojiSearch.Emoji, Integer>> reactions, Set<String> ourReactions) {
             this.reactions = reactions;
             this.ourReactions = ourReactions;
         }

src/main/java/eu/siacs/conversations/ui/BindingAdapters.java 🔗

@@ -4,6 +4,8 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.util.TypedValue;
 
+import com.cheogram.android.EmojiSearch;
+
 import com.google.android.material.chip.Chip;
 import com.google.android.material.chip.ChipGroup;
 import com.google.android.material.color.MaterialColors;
@@ -15,7 +17,6 @@ import eu.siacs.conversations.entities.Reaction;
 
 import java.util.Collection;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.function.Consumer;
 
@@ -25,15 +26,16 @@ public class BindingAdapters {
             final ChipGroup chipGroup,
             final Reaction.Aggregated reactions,
             final Consumer<Collection<String>> onModifiedReactions,
+            final Consumer<EmojiSearch.CustomEmoji> onCustomReaction,
             final Runnable addReaction) {
-        setReactions(chipGroup, reactions, true, onModifiedReactions, addReaction);
+        setReactions(chipGroup, reactions, true, onModifiedReactions, onCustomReaction, addReaction);
     }
 
     public static void setReactionsOnSent(
             final ChipGroup chipGroup,
             final Reaction.Aggregated reactions,
             final Consumer<Collection<String>> onModifiedReactions) {
-        setReactions(chipGroup, reactions, false, onModifiedReactions, null);
+        setReactions(chipGroup, reactions, false, onModifiedReactions, null, null);
     }
 
     private static void setReactions(
@@ -41,34 +43,29 @@ public class BindingAdapters {
             final Reaction.Aggregated aggregated,
             final boolean onReceived,
             final Consumer<Collection<String>> onModifiedReactions,
+            final Consumer<EmojiSearch.CustomEmoji> onCustomReaction,
             final Runnable addReaction) {
         final var context = chipGroup.getContext();
         final var size = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 35, context.getResources().getDisplayMetrics());
         final var corner = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 35, context.getResources().getDisplayMetrics());
         final var layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, size);
-        final List<Map.Entry<String, Integer>> reactions = aggregated.reactions;
+        final List<Map.Entry<EmojiSearch.Emoji, Integer>> reactions = aggregated.reactions;
         if (reactions == null || reactions.isEmpty()) {
             chipGroup.setVisibility(View.GONE);
         } else {
             chipGroup.removeAllViews();
             chipGroup.setVisibility(View.VISIBLE);
-            for (final Map.Entry<String, Integer> reaction : reactions) {
+            for (final var reaction : reactions) {
                 final var emoji = reaction.getKey();
                 final var count = reaction.getValue();
                 final Chip chip = new Chip(chipGroup.getContext());
                 //chip.setEnsureMinTouchTargetSize(false);
                 chip.setChipMinHeight(size-32.0f);
                 chip.ensureAccessibleTouchTarget(size);
-                chip.setChipStartPadding(0.0f);
-                chip.setChipEndPadding(0.0f);
-                chip.setChipCornerRadius(corner);
                 chip.setLayoutParams(layoutParams);
-                if (count == 1) {
-                    chip.setText(emoji);
-                } else {
-                    chip.setText(String.format(Locale.ENGLISH, "%s %d", emoji, count));
-                }
-                final boolean oneOfOurs = aggregated.ourReactions.contains(emoji);
+                chip.setChipCornerRadius(corner);
+                emoji.setupChip(chip, count);
+                final boolean oneOfOurs = aggregated.ourReactions.contains(emoji.toString());
                 // received = surface; sent = surface high matches bubbles
                 if (oneOfOurs) {
                     chip.setChipBackgroundColor(
@@ -82,6 +79,8 @@ public class BindingAdapters {
                                     context,
                                     com.google.android.material.R.attr.colorSurfaceContainerLow));
                 }
+                chip.setTextEndPadding(0.0f);
+                chip.setTextStartPadding(0.0f);
                 chip.setOnClickListener(
                         v -> {
                             if (oneOfOurs) {
@@ -89,13 +88,17 @@ public class BindingAdapters {
                                         ImmutableSet.copyOf(
                                                 Collections2.filter(
                                                         aggregated.ourReactions,
-                                                        r -> !r.equals(emoji))));
+                                                        r -> !r.equals(emoji.toString()))));
                             } else {
-                                onModifiedReactions.accept(
+                                if (emoji instanceof EmojiSearch.CustomEmoji) {
+                                    onCustomReaction.accept((EmojiSearch.CustomEmoji) emoji);
+                                } else {
+                                    onModifiedReactions.accept(
                                         new ImmutableSet.Builder<String>()
                                                 .addAll(aggregated.ourReactions)
-                                                .add(emoji)
+                                                .add(emoji.toString())
                                                 .build());
+                                }
                             }
                         });
                 chipGroup.addView(chip);
@@ -113,11 +116,11 @@ public class BindingAdapters {
                 //                com.google.android.material.R.attr.colorTertiary));
                 chip.setChipBackgroundColor(
                         MaterialColors.getColorStateListOrNull(
-                                chipGroup.getContext(),
+                                context,
                                 com.google.android.material.R.attr.colorSurfaceContainerLow));
                 chip.setChipIconTint(
                         MaterialColors.getColorStateListOrNull(
-                                chipGroup.getContext(),
+                                context,
                                 com.google.android.material.R.attr.colorOnSurface));
                 //chip.setEnsureMinTouchTargetSize(false);
                 chip.setTextEndPadding(0.0f);

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -60,6 +60,8 @@ import com.google.android.material.shape.CornerFamily;
 import com.google.android.material.shape.ShapeAppearanceModel;
 
 import com.cheogram.android.BobTransfer;
+import com.cheogram.android.EmojiSearch;
+import com.cheogram.android.GetThumbnailForCid;
 import com.cheogram.android.MessageTextActionModeCallback;
 import com.cheogram.android.SwipeDetector;
 import com.cheogram.android.Util;
@@ -90,6 +92,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Locale;
+import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -111,6 +114,7 @@ import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message.FileParams;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
+import eu.siacs.conversations.entities.Reaction;
 import eu.siacs.conversations.entities.Roster;
 import eu.siacs.conversations.entities.RtpSessionStatus;
 import eu.siacs.conversations.entities.Transferable;
@@ -592,27 +596,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
     private SpannableStringBuilder getSpannableBody(final Message message) {
         Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), R.drawable.ic_photo_24dp, null);
-        return message.getMergedBody((cid) -> {
-            try {
-                DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
-                if (f == null || !f.canRead()) {
-                    if (!message.trusted() && !message.getConversation().canInferPresence()) return null;
-
-                    try {
-                        new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
-                    } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
-                    return null;
-                }
-
-                Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
-                if (d == null) {
-                    new ThumbnailTask().execute(f);
-                }
-                return d;
-            } catch (final IOException e) {
-                return null;
-            }
-        }, fallbackImg);
+        return message.getMergedBody(new Thumbnailer(message), fallbackImg);
     }
 
     private void displayTextMessage(
@@ -1499,6 +1483,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         setTextColor(viewHolder.messageBody, bubbleColor);
         viewHolder.messageBody.setLinkTextColor(bubbleToOnSurfaceColor(viewHolder.messageBody, bubbleColor));
 
+        final Function<Reaction, GetThumbnailForCid> reactionThumbnailer = (r) -> new Thumbnailer(conversation.getAccount(), r, conversation.canInferPresence());
         if (type == RECEIVED) {
             if (!muted && commands != null && conversation instanceof Conversation) {
                 CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
@@ -1532,16 +1517,20 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                             CryptoHelper.encryptionTypeToText(message.getEncryption()));
                 }
             }
+            final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
             BindingAdapters.setReactionsOnReceived(
                     viewHolder.reactions,
-                    conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message) : message.getAggregatedReactions(),
+                    aggregatedReactions,
                     reactions -> sendReactions(message, reactions),
+                    emoji -> sendCustomReaction(message, emoji),
                     () -> addReaction(message));
         } else if (type == SENT) {
+            final var aggregatedReactions = conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message, reactionThumbnailer) : message.getAggregatedReactions();
             BindingAdapters.setReactionsOnReceived(
                     viewHolder.reactions,
-                    conversation instanceof Conversation ? ((Conversation) conversation).aggregatedReactionsFor(message) : message.getAggregatedReactions(),
+                    aggregatedReactions,
                     reactions -> sendReactions(message, reactions),
+                    emoji -> sendCustomReaction(message, emoji),
                     () -> addReaction(message));
         }
 
@@ -1617,6 +1606,13 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
     }
 
+    private void sendCustomReaction(final Message inReplyTo, final EmojiSearch.CustomEmoji emoji) {
+        final var message = inReplyTo.reply();
+        message.appendBody(emoji.toInsert());
+        Message.configurePrivateMessage(message);
+        new Thread(() -> activity.xmppConnectionService.sendMessage(message)).start();
+    }
+
     private void addReaction(final Message message) {
         activity.addReaction(message, reactions -> activity.xmppConnectionService.sendReactions(message,reactions));
     }
@@ -1831,6 +1827,47 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         protected ChipGroup reactions;
     }
 
+    class Thumbnailer implements GetThumbnailForCid {
+        final Account account;
+        final boolean canFetch;
+        final Jid counterpart;
+
+        public Thumbnailer(final Message message) {
+            account = message.getConversation().getAccount();
+            canFetch = message.trusted() || message.getConversation().canInferPresence();
+            counterpart = message.getCounterpart();
+        }
+
+        public Thumbnailer(final Account account, final Reaction reaction, final boolean allowFetch) {
+            canFetch = allowFetch;
+            counterpart = reaction.from;
+            this.account = account;
+        }
+
+        @Override
+        public Drawable getThumbnail(Cid cid) {
+            try {
+                DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
+                if (f == null || !f.canRead()) {
+                    if (!canFetch) return null;
+
+                    try {
+                        new BobTransfer(BobTransfer.uri(cid), account, counterpart, activity.xmppConnectionService).start();
+                    } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
+                    return null;
+                }
+
+                Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
+                if (d == null) {
+                    new ThumbnailTask().execute(f);
+                }
+                return d;
+            } catch (final IOException e) {
+                return null;
+            }
+        }
+    }
+
     class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
         @Override
         protected Drawable[] doInBackground(DownloadableFile... params) {