Detailed changes
@@ -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
@@ -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) {
@@ -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 {
}
@@ -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;
+ }
}
}
@@ -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;
+ }
+ }
}
@@ -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);
@@ -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);
@@ -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")) {
@@ -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(
@@ -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;
}
@@ -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);
+ }
+ }
+ }
+}
@@ -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);
@@ -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());
}
@@ -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);
}
@@ -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);
@@ -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;
}
}
@@ -589,6 +589,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection
}
terminateTransport();
final State target = reasonToState(wrapper.reason);
+ // TODO check if we were already terminated
transitionOrThrow(target);
finish();
}
@@ -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>
@@ -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>
@@ -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" />
@@ -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>
@@ -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>
@@ -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"
@@ -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>