rudimentary 'XEP-0444: Message Reactions' support

Daniel Gultsch created

Change summary

build.gradle                                                                       |   7 
src/main/java/eu/siacs/conversations/entities/Conversation.java                    |  11 
src/main/java/eu/siacs/conversations/entities/Message.java                         |  12 
src/main/java/eu/siacs/conversations/entities/MucOptions.java                      |   9 
src/main/java/eu/siacs/conversations/entities/Reaction.java                        | 149 
src/main/java/eu/siacs/conversations/generator/MessageGenerator.java               |  18 
src/main/java/eu/siacs/conversations/parser/AbstractParser.java                    |   4 
src/main/java/eu/siacs/conversations/parser/MessageParser.java                     |  63 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java                    |   6 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java           |  48 
src/main/java/eu/siacs/conversations/ui/BindingAdapters.java                       | 117 
src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java                |   7 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java                  |  35 
src/main/java/eu/siacs/conversations/ui/XmppActivity.java                          |  38 
src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java               |   6 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java                |  37 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java |   1 
src/main/res/drawable/ic_add_reaction_24dp.xml                                     |  12 
src/main/res/layout/dialog_add_reaction.xml                                        |  18 
src/main/res/layout/item_emoji_button.xml                                          |   8 
src/main/res/layout/item_message_received.xml                                      |  43 
src/main/res/layout/item_message_sent.xml                                          |  43 
src/main/res/layout/item_tag.xml                                                   |   0 
src/main/res/menu/message_context.xml                                              |   5 
src/main/res/values/strings.xml                                                    |   2 
25 files changed, 644 insertions(+), 55 deletions(-)

Detailed changes

build.gradle πŸ”—

@@ -53,11 +53,12 @@ dependencies {
     implementation 'androidx.cardview:cardview:1.0.0'
     implementation "androidx.preference:preference:1.2.1"
     implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
-    implementation 'com.google.android.material:material:1.12.0'
+    implementation 'com.google.android.material:material:1.13.0-alpha06'
     implementation 'androidx.work:work-runtime:2.9.1'
 
     implementation "androidx.emoji2:emoji2:1.5.0"
     freeImplementation "androidx.emoji2:emoji2-bundled:1.5.0"
+    implementation "androidx.emoji2:emoji2-emojipicker:1.5.0"
 
     implementation 'org.bouncycastle:bcmail-jdk18on:1.78.1'
     implementation 'com.google.zxing:core:3.5.3'
@@ -102,8 +103,8 @@ android {
     defaultConfig {
         minSdkVersion 23
         targetSdkVersion 34
-        versionCode 42118
-        versionName "2.16.7"
+        versionCode 42119
+        versionName "2.17.0-beta"
         archivesBaseName += "-$versionName"
         applicationId "eu.siacs.conversations"
         resValue "string", "applicationId", applicationId

src/main/java/eu/siacs/conversations/entities/Conversation.java πŸ”—

@@ -394,6 +394,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         return null;
     }
 
+    public Message findMessageWithUuidOrRemoteId(final String id) {
+        synchronized (this.messages) {
+            for (final Message message : this.messages) {
+                if (id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId())) {
+                    return message;
+                }
+            }
+        }
+        return null;
+    }
+
     public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
         synchronized (this.messages) {
             for (int i = this.messages.size() - 1; i >= 0; --i) {

src/main/java/eu/siacs/conversations/entities/Message.java πŸ”—

@@ -733,6 +733,18 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         return this.occupantId;
     }
 
+    public Collection<Reaction> getReactions() {
+        return this.reactions;
+    }
+
+    public Reaction.Aggregated getAggregatedReactions() {
+        return Reaction.aggregated(this.reactions);
+    }
+
+    public void setReactions(final Collection<Reaction> reactions) {
+        this.reactions = reactions;
+    }
+
     public static class MergeSeparator {
     }
 

src/main/java/eu/siacs/conversations/entities/MucOptions.java πŸ”—

@@ -764,6 +764,7 @@ public class MucOptions {
         private Avatar avatar;
         private final MucOptions options;
         private ChatState chatState = Config.DEFAULT_CHAT_STATE;
+        private String occupantId;
 
         public User(MucOptions options, Jid fullJid) {
             this.options = options;
@@ -927,5 +928,13 @@ public class MucOptions {
         public String getAvatarName() {
             return getConversation().getName().toString();
         }
+
+        public void setOccupantId(final String occupantId) {
+            this.occupantId = occupantId;
+        }
+
+        public String getOccupantId() {
+            return this.occupantId;
+        }
     }
 }

src/main/java/eu/siacs/conversations/entities/Reaction.java πŸ”—

@@ -1,43 +1,178 @@
 package eu.siacs.conversations.entities;
 
+import androidx.annotation.NonNull;
+
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimaps;
+import com.google.common.collect.Ordering;
 import com.google.common.reflect.TypeToken;
 import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
 import com.google.gson.JsonSyntaxException;
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
 
 import eu.siacs.conversations.xmpp.Jid;
 
+import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public class Reaction {
 
-    private static final Gson GSON = new Gson();
+    public static final List<String> SUGGESTIONS =
+            Arrays.asList(
+                    "\u2764\uFE0F",
+                    "\uD83D\uDC4D",
+                    "\uD83D\uDC4E",
+                    "\uD83D\uDE02",
+                    "\uD83D\uDE2E",
+                    "\uD83D\uDE22");
+
+    private static final Gson GSON;
+
+    static {
+        GSON = new GsonBuilder().registerTypeAdapter(Jid.class, new JidTypeAdapter()).create();
+    }
 
     public final String reaction;
-    public final Jid jid;
+    public final boolean received;
+    public final Jid from;
+    public final Jid trueJid;
     public final String occupantId;
 
-    public Reaction(final String reaction, final Jid jid, final String occupantId) {
+    public Reaction(
+            final String reaction,
+            boolean received,
+            final Jid from,
+            final Jid trueJid,
+            final String occupantId) {
         this.reaction = reaction;
-        this.jid = jid;
+        this.received = received;
+        this.from = from;
+        this.trueJid = trueJid;
         this.occupantId = occupantId;
     }
 
-
     public static String toString(final Collection<Reaction> reactions) {
         return (reactions == null || reactions.isEmpty()) ? null : GSON.toJson(reactions);
     }
 
     public static Collection<Reaction> fromString(final String asString) {
-        if ( Strings.isNullOrEmpty(asString)) {
+        if (Strings.isNullOrEmpty(asString)) {
             return Collections.emptyList();
         }
         try {
-            return GSON.fromJson(asString,new TypeToken<ArrayList<Reaction>>(){}.getType());
+            return GSON.fromJson(asString, new TypeToken<ArrayList<Reaction>>() {}.getType());
         } catch (final JsonSyntaxException e) {
             return Collections.emptyList();
         }
     }
+
+    public static Collection<Reaction> withOccupantId(
+            final Collection<Reaction> existing,
+            final Collection<String> reactions,
+            final boolean received,
+            final Jid from,
+            final Jid trueJid,
+            final String occupantId) {
+        final ImmutableList.Builder<Reaction> builder = new ImmutableList.Builder<>();
+        builder.addAll(Collections2.filter(existing, e -> !occupantId.equals(e.occupantId)));
+        builder.addAll(
+                Collections2.transform(
+                        reactions, r -> new Reaction(r, received, from, trueJid, occupantId)));
+        return builder.build();
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+                .add("reaction", reaction)
+                .add("received", received)
+                .add("from", from)
+                .add("trueJid", trueJid)
+                .add("occupantId", occupantId)
+                .toString();
+    }
+
+    public static Collection<Reaction> withFrom(
+            final Collection<Reaction> existing,
+            final Collection<String> reactions,
+            final boolean received,
+            final Jid from) {
+        final ImmutableList.Builder<Reaction> builder = new ImmutableList.Builder<>();
+        builder.addAll(
+                Collections2.filter(existing, e -> !from.asBareJid().equals(e.from.asBareJid())));
+        builder.addAll(
+                Collections2.transform(
+                        reactions, r -> new Reaction(r, received, from, null, null)));
+        return builder.build();
+    }
+
+    private static class JidTypeAdapter extends TypeAdapter<Jid> {
+        @Override
+        public void write(final JsonWriter out, final Jid value) throws IOException {
+            if (value == null) {
+                out.nullValue();
+            } else {
+                out.value(value.toEscapedString());
+            }
+        }
+
+        @Override
+        public Jid 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 Jid.ofEscaped(value);
+            }
+            throw new IOException("Unexpected token");
+        }
+    }
+
+    public static Aggregated aggregated(final Collection<Reaction> reactions) {
+        final Map<String, Integer> aggregatedReactions =
+                Maps.transformValues(
+                        Multimaps.index(reactions, r -> r.reaction).asMap(), Collection::size);
+        final List<Map.Entry<String, Integer>> sortedList =
+                Ordering.from(
+                                Comparator.comparingInt(
+                                        (Map.Entry<String, Integer> o) -> o.getValue()))
+                        .reverse()
+                        .immutableSortedCopy(aggregatedReactions.entrySet());
+        return new Aggregated(
+                sortedList,
+                ImmutableSet.copyOf(
+                        Collections2.transform(
+                                Collections2.filter(reactions, r -> !r.received),
+                                r -> r.reaction)));
+    }
+
+    public static final class Aggregated {
+
+        public final List<Map.Entry<String, Integer>> reactions;
+        public final Set<String> ourReactions;
+
+        private Aggregated(
+                final List<Map.Entry<String, Integer>> reactions, Set<String> ourReactions) {
+            this.reactions = reactions;
+            this.ourReactions = ourReactions;
+        }
+    }
 }

src/main/java/eu/siacs/conversations/generator/MessageGenerator.java πŸ”—

@@ -2,6 +2,7 @@ package eu.siacs.conversations.generator;
 
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Date;
 import java.util.Locale;
 import java.util.TimeZone;
@@ -22,6 +23,8 @@ import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
 import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
+import im.conversations.android.xmpp.model.reactions.Reaction;
+import im.conversations.android.xmpp.model.reactions.Reactions;
 
 public class MessageGenerator extends AbstractGenerator {
     private static final String OMEMO_FALLBACK_MESSAGE = "I sent you an OMEMO encrypted message but your client doesn’t seem to support that. Find more information on https://conversations.im/omemo";
@@ -167,6 +170,21 @@ public class MessageGenerator extends AbstractGenerator {
         return packet;
     }
 
+    public im.conversations.android.xmpp.model.stanza.Message reaction(final Conversational conversation, final String reactingTo, final Collection<String> ourReactions) {
+        final boolean groupChat = conversation.getMode() == Conversational.MODE_MULTI;
+        final Jid to = conversation.getJid().asBareJid();
+        final im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
+        packet.setType(groupChat ? im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT : im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
+        packet.setTo(to);
+        final var reactions = packet.addExtension(new Reactions());
+        reactions.setId(reactingTo);
+        for(final String ourReaction : ourReactions) {
+            reactions.addExtension(new Reaction(ourReaction));
+        }
+        packet.addChild("store", "urn:xmpp:hints");
+        return packet;
+    }
+
     public im.conversations.android.xmpp.model.stanza.Message conferenceSubject(Conversation conversation, String subject) {
         im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
         packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT);

src/main/java/eu/siacs/conversations/parser/AbstractParser.java πŸ”—

@@ -137,7 +137,7 @@ public abstract class AbstractParser {
 		return parseItem(conference,item, null);
 	}
 
-	public static MucOptions.User parseItem(Conversation conference, Element item, Jid fullJid) {
+	public static MucOptions.User parseItem(final Conversation conference, Element item, Jid fullJid) {
 		final String local = conference.getJid().getLocal();
 		final String domain = conference.getJid().getDomain().toEscapedString();
 		String affiliation = item.getAttribute("affiliation");
@@ -150,7 +150,7 @@ public abstract class AbstractParser {
 				fullJid = null;
 			}
 		}
-		Jid realJid = item.getAttributeAsJid("jid");
+		final Jid realJid = item.getAttributeAsJid("jid");
 		MucOptions.User user = new MucOptions.User(conference.getMucOptions(), fullJid);
 		if (InvalidJid.isValid(realJid)) {
 			user.setRealJid(realJid);

src/main/java/eu/siacs/conversations/parser/MessageParser.java πŸ”—

@@ -32,6 +32,7 @@ import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
+import eu.siacs.conversations.entities.Reaction;
 import eu.siacs.conversations.entities.ReadByMarker;
 import eu.siacs.conversations.entities.ReceiptRequest;
 import eu.siacs.conversations.entities.RtpSessionStatus;
@@ -55,6 +56,7 @@ import im.conversations.android.xmpp.model.carbons.Received;
 import im.conversations.android.xmpp.model.carbons.Sent;
 import im.conversations.android.xmpp.model.forward.Forwarded;
 import im.conversations.android.xmpp.model.occupant.OccupantId;
+import im.conversations.android.xmpp.model.reactions.Reactions;
 
 public class MessageParser extends AbstractParser implements Consumer<im.conversations.android.xmpp.model.stanza.Message> {
 
@@ -430,7 +432,8 @@ public class MessageParser extends AbstractParser implements Consumer<im.convers
         } else if (query != null) {
             Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received mam result with invalid from (" + original.getFrom() + ") or queryId (" + queryId + ")");
             return;
-        } else if (original.fromServer(account)) {
+        } else if (original.fromServer(account)
+                && original.getType() != im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT) {
             Pair<im.conversations.android.xmpp.model.stanza.Message, Long> f;
             f = getForwardedMessagePacket(original, Received.class);
             f = f == null ? getForwardedMessagePacket(original, Sent.class) : f;
@@ -447,6 +450,7 @@ public class MessageParser extends AbstractParser implements Consumer<im.convers
         if (timestamp == null) {
             timestamp = AbstractParser.parseTimestamp(original, AbstractParser.parseTimestamp(packet));
         }
+        final Reactions reactions = packet.getExtension(Reactions.class);
         final LocalizedContent body = packet.getBody();
         final Element mucUserElement = packet.findChild("x", Namespace.MUC_USER);
         final String pgpEncrypted = packet.findChildContent("x", "jabber:x:encrypted");
@@ -1038,6 +1042,7 @@ public class MessageParser extends AbstractParser implements Consumer<im.convers
         final Element displayed = packet.findChild("displayed", "urn:xmpp:chat-markers:0");
         if (displayed != null) {
             final String id = displayed.getAttribute("id");
+            // TODO we don’t even use 'sender' any more. Remove this!
             final Jid sender = InvalidJid.getNullForInvalid(displayed.getAttributeAsJid("sender"));
             if (packet.fromAccount(account) && !selfAddressed) {
                 final Conversation c =
@@ -1063,7 +1068,9 @@ public class MessageParser extends AbstractParser implements Consumer<im.convers
                     message = null;
                 }
                 if (message != null) {
+                    // TODO use occupantId to extract true counterpart from presence
                     final Jid fallback = conversation.getMucOptions().getTrueCounterpart(counterpart);
+                    // TODO try to externalize mucTrueCounterpart
                     final Jid trueJid = getTrueCounterpart((query != null && query.safeToExtractTrueCounterpart()) ? mucUserElement : null, fallback);
                     final boolean trueJidMatchesAccount = account.getJid().asBareJid().equals(trueJid == null ? null : trueJid.asBareJid());
                     if (trueJidMatchesAccount || conversation.getMucOptions().isSelf(counterpart)) {
@@ -1102,6 +1109,60 @@ public class MessageParser extends AbstractParser implements Consumer<im.convers
             }
         }
 
+        if (reactions != null) {
+            final String reactingTo = reactions.getId();
+            final Conversation conversation =
+                    mXmppConnectionService.find(account, counterpart.asBareJid());
+
+            if (conversation != null) {
+                if (isTypeGroupChat && conversation.getMode() == Conversational.MODE_MULTI) {
+                    final var mucOptions = conversation.getMucOptions();
+                    final var occupant =
+                            mucOptions.occupantId() ? packet.getExtension(OccupantId.class) : null;
+                    final var occupantId = occupant == null ? null : occupant.getId();
+                    final var message = conversation.findMessageWithServerMsgId(reactingTo);
+                    // TODO use occupant id for isSelf assessment
+                    final boolean isReceived = !mucOptions.isSelf(counterpart);
+                    if (occupantId != null && message != null) {
+                        final var combinedReactions =
+                                Reaction.withOccupantId(
+                                        message.getReactions(),
+                                        reactions.getReactions(),
+                                        isReceived,
+                                        counterpart,
+                                        null,
+                                        occupantId);
+                        message.setReactions(combinedReactions);
+                        mXmppConnectionService.updateMessage(message, false);
+                    } else {
+                        Log.d(Config.LOGTAG,"not found occupant or message");
+                    }
+                } else if (conversation.getMode() == Conversational.MODE_SINGLE) {
+                    final var message = conversation.findMessageWithUuidOrRemoteId(reactingTo);
+                    final boolean isReceived;
+                    final Jid reactionFrom;
+                    if (packet.fromAccount(account)) {
+                        isReceived = false;
+                        reactionFrom = account.getJid().asBareJid();
+                    } else {
+                        isReceived = true;
+                        reactionFrom = counterpart;
+                    }
+                    packet.fromAccount(account);
+                    if (message != null) {
+                        final var combinedReactions =
+                                Reaction.withFrom(
+                                        message.getReactions(),
+                                        reactions.getReactions(),
+                                        isReceived,
+                                        reactionFrom);
+                        message.setReactions(combinedReactions);
+                        mXmppConnectionService.updateMessage(message, false);
+                    }
+                }
+            }
+        }
+
         final Element event = original.findChild("event", "http://jabber.org/protocol/pubsub#event");
         if (event != null && InvalidJid.hasValidFrom(original) && original.getFrom().isBareJid()) {
             if (event.hasChild("items")) {

src/main/java/eu/siacs/conversations/parser/PresenceParser.java πŸ”—

@@ -20,6 +20,7 @@ import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.InvalidJid;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.pep.Avatar;
+import im.conversations.android.xmpp.model.occupant.OccupantId;
 
 import org.openintents.openpgp.util.OpenPgpUtils;
 
@@ -72,7 +73,10 @@ public class PresenceParser extends AbstractParser implements Consumer<im.conver
                     Element item = x.findChild("item");
                     if (item != null && !from.isBareJid()) {
                         mucOptions.setError(MucOptions.Error.NONE);
-                        MucOptions.User user = parseItem(conversation, item, from);
+                        final MucOptions.User user = parseItem(conversation, item, from);
+                        final var occupant = packet.getExtension(OccupantId.class);
+                        final String occupantId = mucOptions.occupantId() && occupant != null ? occupant.getId() : null;
+                        user.setOccupantId(occupantId);
                         if (codes.contains(MucOptions.STATUS_CODE_SELF_PRESENCE)
                                 || (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED)
                                         && jid.equals(

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java πŸ”—

@@ -114,6 +114,7 @@ import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.MucOptions.OnRenameListener;
 import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.entities.PresenceTemplate;
+import eu.siacs.conversations.entities.Reaction;
 import eu.siacs.conversations.entities.Roster;
 import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.generator.AbstractGenerator;
@@ -4632,6 +4633,53 @@ public class XmppConnectionService extends Service {
                 PublishOptions.persistentWhitelistAccessMaxItems());
     }
 
+    public boolean sendReactions(final Message message, final Collection<String> reactions) {
+        if (message.getConversation() instanceof Conversation conversation) {
+            final String reactToId;
+            final Collection<Reaction> combinedReactions;
+            if (conversation.getMode() == Conversational.MODE_MULTI) {
+                final var self = conversation.getMucOptions().getSelf();
+                final String occupantId = self.getOccupantId();
+                if (Strings.isNullOrEmpty(occupantId)) {
+                    Log.d(Config.LOGTAG, "occupant id not found for reaction in MUC");
+                    return false;
+                }
+                reactToId = message.getServerMsgId();
+                combinedReactions =
+                        Reaction.withOccupantId(
+                                message.getReactions(),
+                                reactions,
+                                false,
+                                self.getFullJid(),
+                                conversation.getAccount().getJid(),
+                                occupantId);
+            } else {
+                if (message.isCarbon() || message.getStatus() == Message.STATUS_RECEIVED) {
+                    reactToId = message.getRemoteMsgId();
+                } else {
+                    reactToId = message.getUuid();
+                }
+                combinedReactions =
+                        Reaction.withFrom(
+                                message.getReactions(),
+                                reactions,
+                                false,
+                                conversation.getAccount().getJid());
+            }
+            if (Strings.isNullOrEmpty(reactToId)) {
+                return false;
+            }
+            final var reactionMessage =
+                    mMessageGenerator.reaction(conversation, reactToId, reactions);
+            sendMessagePacket(conversation.getAccount(), reactionMessage);
+            message.setReactions(combinedReactions);
+            updateMessage(message, false);
+            return true;
+        } else {
+            return false;
+        }
+    }
+
     public MemorizingTrustManager getMemorizingTrustManager() {
         return this.mMemorizingTrustManager;
     }

src/main/java/eu/siacs/conversations/ui/BindingAdapters.java πŸ”—

@@ -0,0 +1,117 @@
+package eu.siacs.conversations.ui;
+
+import android.view.View;
+
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.color.MaterialColors;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableSet;
+
+import eu.siacs.conversations.R;
+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;
+
+public class BindingAdapters {
+
+    public static void setReactionsOnReceived(
+            final ChipGroup chipGroup,
+            final Reaction.Aggregated reactions,
+            final Consumer<Collection<String>> onModifiedReactions,
+            final Runnable addReaction) {
+        setReactions(chipGroup, reactions, true, onModifiedReactions, addReaction);
+    }
+
+    public static void setReactionsOnSent(
+            final ChipGroup chipGroup,
+            final Reaction.Aggregated reactions,
+            final Consumer<Collection<String>> onModifiedReactions) {
+        setReactions(chipGroup, reactions, false, onModifiedReactions, null);
+    }
+
+    private static void setReactions(
+            final ChipGroup chipGroup,
+            final Reaction.Aggregated aggregated,
+            final boolean onReceived,
+            final Consumer<Collection<String>> onModifiedReactions,
+            final Runnable addReaction) {
+        final var context = chipGroup.getContext();
+        final List<Map.Entry<String, 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) {
+                final var emoji = reaction.getKey();
+                final var count = reaction.getValue();
+                final Chip chip = new Chip(chipGroup.getContext());
+                chip.setEnsureMinTouchTargetSize(false);
+                chip.setChipStartPadding(0.0f);
+                chip.setChipEndPadding(0.0f);
+                if (count == 1) {
+                    chip.setText(emoji);
+                } else {
+                    chip.setText(String.format(Locale.ENGLISH, "%s %d", emoji, count));
+                }
+                final boolean oneOfOurs = aggregated.ourReactions.contains(emoji);
+                // received = surface; sent = surface high matches bubbles
+                if (oneOfOurs) {
+                    chip.setChipBackgroundColor(
+                            MaterialColors.getColorStateListOrNull(
+                                    context,
+                                    com.google.android.material.R.attr
+                                            .colorSurfaceContainerHighest));
+                } else {
+                    chip.setChipBackgroundColor(
+                            MaterialColors.getColorStateListOrNull(
+                                    context,
+                                    com.google.android.material.R.attr.colorSurfaceContainerLow));
+                }
+                chip.setOnClickListener(
+                        v -> {
+                            if (oneOfOurs) {
+                                onModifiedReactions.accept(
+                                        ImmutableSet.copyOf(
+                                                Collections2.filter(
+                                                        aggregated.ourReactions,
+                                                        r -> !r.equals(emoji))));
+                            } else {
+                                onModifiedReactions.accept(
+                                        new ImmutableSet.Builder<String>()
+                                                .addAll(aggregated.ourReactions)
+                                                .add(emoji)
+                                                .build());
+                            }
+                        });
+                chipGroup.addView(chip);
+            }
+            if (onReceived) {
+                final Chip chip = new Chip(chipGroup.getContext());
+                chip.setChipIconResource(R.drawable.ic_add_reaction_24dp);
+                chip.setChipStrokeColor(
+                        MaterialColors.getColorStateListOrNull(
+                                chipGroup.getContext(),
+                                com.google.android.material.R.attr.colorTertiary));
+                chip.setChipBackgroundColor(
+                        MaterialColors.getColorStateListOrNull(
+                                chipGroup.getContext(),
+                                com.google.android.material.R.attr.colorTertiaryContainer));
+                chip.setChipIconTint(
+                        MaterialColors.getColorStateListOrNull(
+                                chipGroup.getContext(),
+                                com.google.android.material.R.attr.colorOnTertiaryContainer));
+                chip.setEnsureMinTouchTargetSize(false);
+                chip.setTextEndPadding(0.0f);
+                chip.setTextStartPadding(0.0f);
+                chip.setOnClickListener(v -> addReaction.run());
+                chipGroup.addView(chip);
+            }
+        }
+    }
+}

src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java πŸ”—

@@ -28,7 +28,6 @@ import android.widget.TextView;
 import android.widget.Toast;
 
 import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
 import androidx.core.content.ContextCompat;
 import androidx.core.view.ViewCompat;
 import androidx.databinding.DataBindingUtil;
@@ -521,7 +520,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp
             final ImmutableList.Builder<Integer> viewIdBuilder = new ImmutableList.Builder<>();
             for (final ListItem.Tag tag : tagList) {
                 final String name = tag.getName();
-                final TextView tv = (TextView) inflater.inflate(R.layout.list_item_tag, binding.tags, false);
+                final TextView tv = (TextView) inflater.inflate(R.layout.item_tag, binding.tags, false);
                 tv.setText(name);
                 tv.setBackgroundTintList(ColorStateList.valueOf(MaterialColors.harmonizeWithPrimary(this,XEP0392Helper.rgbFromNick(name))));
                 final int id = ViewCompat.generateViewId();
@@ -533,7 +532,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp
                 final TextView tv =
                         (TextView)
                                 inflater.inflate(
-                                        R.layout.list_item_tag, binding.tags, false);
+                                        R.layout.item_tag, binding.tags, false);
                 tv.setText(R.string.blocked);
                 tv.setBackgroundTintList(ColorStateList.valueOf(MaterialColors.harmonizeWithPrimary(tv.getContext(), ContextCompat.getColor(tv.getContext(),R.color.gray_800))));
                 final int id = ViewCompat.generateViewId();
@@ -546,7 +545,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp
                     final TextView tv =
                             (TextView)
                                     inflater.inflate(
-                                            R.layout.list_item_tag, binding.tags, false);
+                                            R.layout.item_tag, binding.tags, false);
                     UIHelper.setStatus(tv, status);
                     final int id = ViewCompat.generateViewId();
                     tv.setId(id);

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java πŸ”—

@@ -1310,20 +1310,21 @@ public class ConversationFragment extends XmppFragment
                                     || t instanceof HttpDownloadConnection);
             activity.getMenuInflater().inflate(R.menu.message_context, menu);
             menu.setHeaderTitle(R.string.message_options);
+            final MenuItem addReaction = menu.findItem(R.id.action_add_reaction);
             final MenuItem reportAndBlock = menu.findItem(R.id.action_report_and_block);
-            MenuItem openWith = menu.findItem(R.id.open_with);
-            MenuItem copyMessage = menu.findItem(R.id.copy_message);
-            MenuItem copyLink = menu.findItem(R.id.copy_link);
-            MenuItem quoteMessage = menu.findItem(R.id.quote_message);
-            MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
-            MenuItem correctMessage = menu.findItem(R.id.correct_message);
-            MenuItem shareWith = menu.findItem(R.id.share_with);
-            MenuItem sendAgain = menu.findItem(R.id.send_again);
-            MenuItem copyUrl = menu.findItem(R.id.copy_url);
-            MenuItem downloadFile = menu.findItem(R.id.download_file);
-            MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission);
-            MenuItem deleteFile = menu.findItem(R.id.delete_file);
-            MenuItem showErrorMessage = menu.findItem(R.id.show_error_message);
+            final MenuItem openWith = menu.findItem(R.id.open_with);
+            final MenuItem copyMessage = menu.findItem(R.id.copy_message);
+            final MenuItem copyLink = menu.findItem(R.id.copy_link);
+            final MenuItem quoteMessage = menu.findItem(R.id.quote_message);
+            final MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
+            final MenuItem correctMessage = menu.findItem(R.id.correct_message);
+            final MenuItem shareWith = menu.findItem(R.id.share_with);
+            final MenuItem sendAgain = menu.findItem(R.id.send_again);
+            final MenuItem copyUrl = menu.findItem(R.id.copy_url);
+            final MenuItem downloadFile = menu.findItem(R.id.download_file);
+            final MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission);
+            final MenuItem deleteFile = menu.findItem(R.id.delete_file);
+            final MenuItem showErrorMessage = menu.findItem(R.id.show_error_message);
             final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(m);
             final boolean showError =
                     m.getStatus() == Message.STATUS_SEND_FAILED
@@ -1340,6 +1341,7 @@ public class ConversationFragment extends XmppFragment
                     reportAndBlock.setVisible(true);
                 }
             }
+            addReaction.setVisible(!showError && !m.isDeleted());
             if (!m.isFileOrImage()
                     && !encrypted
                     && !m.isGeoUri()
@@ -1466,6 +1468,9 @@ public class ConversationFragment extends XmppFragment
             case R.id.action_report_and_block:
                 reportMessage(selectedMessage);
                 return true;
+            case R.id.action_add_reaction:
+                addReaction(selectedMessage);
+                return true;
             default:
                 return super.onContextItemSelected(item);
         }
@@ -2127,6 +2132,10 @@ public class ConversationFragment extends XmppFragment
         }
     }
 
+    private void addReaction(final Message message) {
+        activity.addReaction(message, reactions -> activity.xmppConnectionService.sendReactions(message, reactions));
+    }
+
     private void reportMessage(final Message message) {
         BlockContactDialog.show(activity, conversation.getContact(), message.getServerMsgId());
     }

src/main/java/eu/siacs/conversations/ui/XmppActivity.java πŸ”—

@@ -53,18 +53,21 @@ import androidx.databinding.DataBindingUtil;
 import com.google.android.material.color.MaterialColors;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.BuildConfig;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.PgpEngine;
+import eu.siacs.conversations.databinding.DialogAddReactionBinding;
 import eu.siacs.conversations.databinding.DialogQuickeditBinding;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.Presences;
+import eu.siacs.conversations.entities.Reaction;
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.services.BarcodeProvider;
 import eu.siacs.conversations.services.QuickConversationsService;
@@ -85,8 +88,11 @@ import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import java.io.IOException;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.RejectedExecutionException;
+import java.util.function.Consumer;
 
 public abstract class XmppActivity extends ActionBarActivity {
 
@@ -291,6 +297,38 @@ public abstract class XmppActivity extends ActionBarActivity {
         builder.create().show();
     }
 
+    public void addReaction(final Message message, Consumer<Collection<String>> callback) {
+        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        final var layoutInflater = this.getLayoutInflater();
+        final DialogAddReactionBinding viewBinding =
+                DataBindingUtil.inflate(layoutInflater, R.layout.dialog_add_reaction, null, false);
+        builder.setView(viewBinding.getRoot());
+        final var dialog = builder.create();
+        for (final String emoji : Reaction.SUGGESTIONS) {
+            final Button button =
+                    (Button)
+                            layoutInflater.inflate(
+                                    R.layout.item_emoji_button, viewBinding.emojis, false);
+            viewBinding.emojis.addView(button);
+            button.setText(emoji);
+            button.setOnClickListener(
+                    v -> {
+                        final var aggregated = message.getAggregatedReactions();
+                        if (aggregated.ourReactions.contains(emoji)) {
+                            callback.accept(aggregated.ourReactions);
+                        } else {
+                            final ImmutableSet.Builder<String> reactionBuilder =
+                                    new ImmutableSet.Builder<>();
+                            reactionBuilder.addAll(aggregated.ourReactions);
+                            reactionBuilder.add(emoji);
+                            callback.accept(reactionBuilder.build());
+                        }
+                        dialog.dismiss();
+                    });
+        }
+        dialog.show();
+    }
+
     protected void deleteAccount(final Account account) {
         this.deleteAccount(account, null);
     }

src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java πŸ”—

@@ -93,7 +93,7 @@ public class ListItemAdapter extends ArrayAdapter<ListItem> {
 			final ImmutableList.Builder<Integer> viewIdBuilder = new ImmutableList.Builder<>();
 			for (final ListItem.Tag tag : tags) {
 				final String name = tag.getName();
-				final TextView tv = (TextView) inflater.inflate(R.layout.list_item_tag, viewHolder.tags, false);
+				final TextView tv = (TextView) inflater.inflate(R.layout.item_tag, viewHolder.tags, false);
 				tv.setText(name);
 				tv.setBackgroundTintList(ColorStateList.valueOf(MaterialColors.harmonizeWithPrimary(getContext(),XEP0392Helper.rgbFromNick(name))));
 				tv.setOnClickListener(this.onTagTvClick);
@@ -107,7 +107,7 @@ public class ListItemAdapter extends ArrayAdapter<ListItem> {
 					final TextView tv =
 							(TextView)
 									inflater.inflate(
-											R.layout.list_item_tag, viewHolder.tags, false);
+											R.layout.item_tag, viewHolder.tags, false);
 					tv.setText(R.string.blocked);
 					tv.setBackgroundTintList(ColorStateList.valueOf(MaterialColors.harmonizeWithPrimary(tv.getContext(),ContextCompat.getColor(tv.getContext(),R.color.gray_800))));
 					final int id = ViewCompat.generateViewId();
@@ -120,7 +120,7 @@ public class ListItemAdapter extends ArrayAdapter<ListItem> {
                         final TextView tv =
                                 (TextView)
                                         inflater.inflate(
-                                                R.layout.list_item_tag, viewHolder.tags, false);
+                                                R.layout.item_tag, viewHolder.tags, false);
 						UIHelper.setStatus(tv, status);
 						final int id = ViewCompat.generateViewId();
 						tv.setId(id);

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java πŸ”—

@@ -15,10 +15,12 @@ import android.text.style.ForegroundColorSpan;
 import android.text.style.RelativeSizeSpan;
 import android.text.style.StyleSpan;
 import android.util.DisplayMetrics;
+import android.util.Log;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.WindowManager;
 import android.widget.ArrayAdapter;
+import android.widget.Button;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.RelativeLayout;
@@ -33,17 +35,24 @@ import androidx.annotation.Nullable;
 import androidx.core.app.ActivityCompat;
 import androidx.core.content.ContextCompat;
 import androidx.core.widget.ImageViewCompat;
+import androidx.databinding.DataBindingUtil;
+import androidx.emoji2.emojipicker.EmojiViewItem;
+import androidx.emoji2.emojipicker.RecentEmojiProvider;
 
 import com.google.android.material.button.MaterialButton;
+import com.google.android.material.chip.ChipGroup;
 import com.google.android.material.color.MaterialColors;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
+import eu.siacs.conversations.databinding.DialogAddReactionBinding;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
@@ -56,6 +65,7 @@ import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.MessageArchiveService;
 import eu.siacs.conversations.services.NotificationService;
 import eu.siacs.conversations.ui.Activities;
+import eu.siacs.conversations.ui.BindingAdapters;
 import eu.siacs.conversations.ui.ConversationFragment;
 import eu.siacs.conversations.ui.ConversationsActivity;
 import eu.siacs.conversations.ui.XmppActivity;
@@ -77,12 +87,14 @@ import eu.siacs.conversations.utils.TimeFrameUtils;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.mam.MamReference;
+import kotlin.coroutines.Continuation;
 
 import java.net.URI;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;
+import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -767,6 +779,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                     viewHolder.time = view.findViewById(R.id.message_time);
                     viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
                     viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
+                    viewHolder.reactions = view.findViewById(R.id.reactions);
                     break;
                 case RECEIVED:
                     view =
@@ -783,6 +796,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                     viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
                     viewHolder.encryption = view.findViewById(R.id.message_encryption);
                     viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
+                    viewHolder.reactions = view.findViewById(R.id.reactions);
                     break;
                 case STATUS:
                     view =
@@ -1059,13 +1073,33 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                             CryptoHelper.encryptionTypeToText(message.getEncryption()));
                 }
             }
+            BindingAdapters.setReactionsOnReceived(
+                    viewHolder.reactions,
+                    message.getAggregatedReactions(),
+                    reactions -> sendReactions(message, reactions),
+                    () -> addReaction(message));
+        } else if (type == SENT) {
+            BindingAdapters.setReactionsOnSent(
+                    viewHolder.reactions,
+                    message.getAggregatedReactions(),
+                    reactions -> sendReactions(message, reactions));
         }
 
         displayStatus(viewHolder, message, type, bubbleColor);
-
         return view;
     }
 
+    private void sendReactions(final Message message, final Collection<String> reactions) {
+        if (activity.xmppConnectionService.sendReactions(message, reactions)) {
+            return;
+        }
+        Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
+    }
+
+    private void addReaction(final Message message) {
+        activity.addReaction(message, reactions -> activity.xmppConnectionService.sendReactions(message,reactions));
+    }
+
     private void promptOpenKeychainInstall(View view) {
         activity.showInstallPgpDialog();
     }
@@ -1260,5 +1294,6 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         protected ImageView contact_picture;
         protected TextView status_message;
         protected TextView encryption;
+        protected ChipGroup reactions;
     }
 }

src/main/res/drawable/ic_add_reaction_24dp.xml πŸ”—

@@ -0,0 +1,12 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M18,9V7h-2V2.84C14.77,2.3 13.42,2 11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12c0,-1.05 -0.17,-2.05 -0.47,-3H18zM15.5,8C16.33,8 17,8.67 17,9.5S16.33,11 15.5,11S14,10.33 14,9.5S14.67,8 15.5,8zM8.5,8C9.33,8 10,8.67 10,9.5S9.33,11 8.5,11S7,10.33 7,9.5S7.67,8 8.5,8zM12,17.5c-2.33,0 -4.31,-1.46 -5.11,-3.5h10.22C16.31,16.04 14.33,17.5 12,17.5zM22,3h2v2h-2v2h-2V5h-2V3h2V1h2V3z" />
+
+</vector>

src/main/res/layout/dialog_add_reaction.xml πŸ”—

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <RelativeLayout
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent">
+
+    <com.google.android.material.button.MaterialButtonGroup
+        android:layout_centerInParent="true"
+
+        android:id="@+id/emojis"
+        style="@style/Widget.Material3.MaterialButtonGroup.Connected"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="?dialogPreferredPadding" />
+    </RelativeLayout>
+
+</layout>

src/main/res/layout/item_emoji_button.xml πŸ”—

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Button xmlns:android="http://schemas.android.com/apk/res/android"
+    style="?attr/materialButtonOutlinedStyle"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:minWidth="0dp"
+    android:paddingHorizontal="8dp"
+    android:textAppearance="?attr/textAppearanceTitleMedium" />

src/main/res/layout/item_message_received.xml πŸ”—

@@ -3,10 +3,9 @@
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools">
 
-    <RelativeLayout
-        android:layout_width="fill_parent"
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:orientation="vertical"
         android:paddingHorizontal="8dp"
         android:paddingVertical="4dp">
 
@@ -14,22 +13,26 @@
             android:id="@+id/message_photo"
             android:layout_width="48dp"
             android:layout_height="48dp"
-            android:layout_alignParentStart="true"
-            android:layout_alignParentTop="true"
-            android:layout_marginEnd="6dp"
             android:scaleType="fitXY"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="@id/message_box"
             app:riv_corner_radius="8dp" />
 
+        <!-- TODO port app:layout_constraintWidth_max="@dimen/message_bubble_max_width" from c3 -->
         <LinearLayout
             android:id="@+id/message_box"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:layout_alignParentBottom="true"
-            android:layout_toEndOf="@+id/message_photo"
+            android:layout_marginStart="6dp"
             android:background="@drawable/background_message_bubble"
             android:backgroundTint="?colorTertiaryContainer"
             android:longClickable="true"
-            android:minHeight="48dp">
+            android:minHeight="48dp"
+            app:layout_constrainedWidth="true"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintHorizontal_bias="0.0"
+            app:layout_constraintStart_toEndOf="@id/message_photo"
+            app:layout_constraintTop_toTopOf="parent">
 
             <LinearLayout
                 android:layout_width="wrap_content"
@@ -94,5 +97,25 @@
                 </LinearLayout>
             </LinearLayout>
         </LinearLayout>
-    </RelativeLayout>
+
+        <Space
+            android:id="@+id/reactions_anchor"
+            android:layout_width="0dp"
+            android:layout_height="0dp"
+            android:layout_marginBottom="4dp"
+            app:layout_constraintBottom_toBottomOf="@+id/message_box"
+            app:layout_constraintStart_toStartOf="@+id/message_box" />
+
+        <com.google.android.material.chip.ChipGroup
+            android:id="@+id/reactions"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="10dp"
+            android:orientation="horizontal"
+            android:visibility="visible"
+            app:chipSpacingHorizontal="4dp"
+            app:layout_constraintHorizontal_bias="0.0"
+            app:layout_constraintStart_toStartOf="@+id/message_box"
+            app:layout_constraintTop_toBottomOf="@+id/reactions_anchor" />
+    </androidx.constraintlayout.widget.ConstraintLayout>
 </layout>

src/main/res/layout/item_message_sent.xml πŸ”—

@@ -2,10 +2,9 @@
 <layout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto">
 
-    <RelativeLayout
-        android:layout_width="fill_parent"
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:orientation="vertical"
         android:paddingHorizontal="8dp"
         android:paddingVertical="4dp">
 
@@ -13,10 +12,9 @@
             android:id="@+id/message_photo"
             android:layout_width="48dp"
             android:layout_height="48dp"
-            android:layout_alignParentEnd="true"
-            android:layout_alignParentBottom="true"
-            android:layout_marginStart="6dp"
             android:scaleType="fitXY"
+            app:layout_constraintBottom_toBottomOf="@id/message_box"
+            app:layout_constraintEnd_toEndOf="parent"
             app:riv_corner_radius="8dp" />
 
 
@@ -24,12 +22,16 @@
             android:id="@+id/message_box"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:layout_alignParentBottom="true"
-            android:layout_toStartOf="@+id/message_photo"
+            android:layout_marginEnd="6dp"
             android:background="@drawable/background_message_bubble"
             android:backgroundTint="?colorSecondaryContainer"
             android:longClickable="true"
-            android:minHeight="48dp">
+            android:minHeight="48dp"
+            app:layout_constrainedWidth="true"
+            app:layout_constraintEnd_toStartOf="@id/message_photo"
+            app:layout_constraintHorizontal_bias="1.0"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent">
 
             <LinearLayout
                 android:layout_width="wrap_content"
@@ -94,5 +96,26 @@
                 </LinearLayout>
             </LinearLayout>
         </LinearLayout>
-    </RelativeLayout>
+
+        <Space
+            android:id="@+id/reactions_anchor"
+            android:layout_width="0dp"
+            android:layout_height="0dp"
+            android:layout_marginBottom="4dp"
+            app:layout_constraintBottom_toBottomOf="@+id/message_box"
+            app:layout_constraintEnd_toEndOf="@+id/message_box" />
+
+        <com.google.android.material.chip.ChipGroup
+            android:id="@+id/reactions"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="10dp"
+            android:orientation="horizontal"
+            android:visibility="visible"
+            app:chipSpacingHorizontal="4dp"
+            app:layout_constraintEnd_toEndOf="@+id/message_box"
+            app:layout_constraintHorizontal_bias="0.0"
+            app:layout_constraintTop_toBottomOf="@+id/reactions_anchor" />
+
+    </androidx.constraintlayout.widget.ConstraintLayout>
 </layout>

src/main/res/menu/message_context.xml πŸ”—

@@ -1,6 +1,11 @@
 <?xml version="1.0" encoding="utf-8"?>
 <menu xmlns:android="http://schemas.android.com/apk/res/android">
 
+    <item
+        android:id="@+id/action_add_reaction"
+        android:title="@string/add_reaction"
+        android:visible="false" />
+
     <item
         android:id="@+id/action_report_and_block"
         android:title="@string/report_spam"

src/main/res/values/strings.xml πŸ”—

@@ -1082,4 +1082,6 @@
     <string name="video_is_enabled_tap_to_disable">Video is enabled. Tap to disable.</string>
     <string name="video_is_disabled_tap_to_enable">Video is disabled. Tap to enable.</string>
     <string name="server_info_login_mechanism">Login mechanism</string>
+    <string name="could_not_add_reaction">Could not add reaction</string>
+    <string name="add_reaction">Add reaction…</string>
 </resources>