diff --git a/build.gradle b/build.gradle index 60f80071c2c958d13a0e0156f2fa9642fea4273d..e1fc3a11742c86c5dec65936df14994d2ef932e3 100644 --- a/build.gradle +++ b/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 diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index 223a48efa238dfdacb02b0139e20bab2a88e17c5..f2b17015f85c84e4afa5c2fbec20b1e679b65a85 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/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) { diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index d0630df0386fc1f9a939caf0c41738c51d176f38..75efa1f3a6147110d951c9d71a591bcc5b0282ed 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/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 getReactions() { + return this.reactions; + } + + public Reaction.Aggregated getAggregatedReactions() { + return Reaction.aggregated(this.reactions); + } + + public void setReactions(final Collection reactions) { + this.reactions = reactions; + } + public static class MergeSeparator { } diff --git a/src/main/java/eu/siacs/conversations/entities/MucOptions.java b/src/main/java/eu/siacs/conversations/entities/MucOptions.java index 7c0c5a8741f6c218ccccae90b78b64143d54a6b7..0acca4600d7df8d3fd7cb5f8364dd9a024985b7c 100644 --- a/src/main/java/eu/siacs/conversations/entities/MucOptions.java +++ b/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; + } } } diff --git a/src/main/java/eu/siacs/conversations/entities/Reaction.java b/src/main/java/eu/siacs/conversations/entities/Reaction.java index c8b1f8b9bef5b66ccb85aff02c34e6a9f14db613..eff7be74b096c1acc7c6020a992d452e025009b4 100644 --- a/src/main/java/eu/siacs/conversations/entities/Reaction.java +++ b/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 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 reactions) { return (reactions == null || reactions.isEmpty()) ? null : GSON.toJson(reactions); } public static Collection fromString(final String asString) { - if ( Strings.isNullOrEmpty(asString)) { + if (Strings.isNullOrEmpty(asString)) { return Collections.emptyList(); } try { - return GSON.fromJson(asString,new TypeToken>(){}.getType()); + return GSON.fromJson(asString, new TypeToken>() {}.getType()); } catch (final JsonSyntaxException e) { return Collections.emptyList(); } } + + public static Collection withOccupantId( + final Collection existing, + final Collection reactions, + final boolean received, + final Jid from, + final Jid trueJid, + final String occupantId) { + final ImmutableList.Builder 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 withFrom( + final Collection existing, + final Collection reactions, + final boolean received, + final Jid from) { + final ImmutableList.Builder 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 { + @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 reactions) { + final Map aggregatedReactions = + Maps.transformValues( + Multimaps.index(reactions, r -> r.reaction).asMap(), Collection::size); + final List> sortedList = + Ordering.from( + Comparator.comparingInt( + (Map.Entry 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> reactions; + public final Set ourReactions; + + private Aggregated( + final List> reactions, Set ourReactions) { + this.reactions = reactions; + this.ourReactions = ourReactions; + } + } } diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java index f3823dee04c2848d6280e58e9b4a453f5386c790..ff5dddb0f7e4f6fd0f4e31362957a139dd491f38 100644 --- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java +++ b/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 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); diff --git a/src/main/java/eu/siacs/conversations/parser/AbstractParser.java b/src/main/java/eu/siacs/conversations/parser/AbstractParser.java index 2e2cb26843ff7ddc2402b958fe656155cc1fed37..ac42857fd1dc96866a9cd011c6c38cf87679d2ee 100644 --- a/src/main/java/eu/siacs/conversations/parser/AbstractParser.java +++ b/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); diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 879a2416d6a5930647007fd971ff5792617b937c..5f05f0a73276e5a50a51611f8f9c88e279fce34d 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/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 { @@ -430,7 +432,8 @@ public class MessageParser extends AbstractParser implements Consumer 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 reactions) { + if (message.getConversation() instanceof Conversation conversation) { + final String reactToId; + final Collection 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; } diff --git a/src/main/java/eu/siacs/conversations/ui/BindingAdapters.java b/src/main/java/eu/siacs/conversations/ui/BindingAdapters.java new file mode 100644 index 0000000000000000000000000000000000000000..6f6b4e8bebba341dbc61ca9c81ec7cb747167b95 --- /dev/null +++ b/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> onModifiedReactions, + final Runnable addReaction) { + setReactions(chipGroup, reactions, true, onModifiedReactions, addReaction); + } + + public static void setReactionsOnSent( + final ChipGroup chipGroup, + final Reaction.Aggregated reactions, + final Consumer> onModifiedReactions) { + setReactions(chipGroup, reactions, false, onModifiedReactions, null); + } + + private static void setReactions( + final ChipGroup chipGroup, + final Reaction.Aggregated aggregated, + final boolean onReceived, + final Consumer> onModifiedReactions, + final Runnable addReaction) { + final var context = chipGroup.getContext(); + final List> reactions = aggregated.reactions; + if (reactions == null || reactions.isEmpty()) { + chipGroup.setVisibility(View.GONE); + } else { + chipGroup.removeAllViews(); + chipGroup.setVisibility(View.VISIBLE); + for (final Map.Entry 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() + .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); + } + } + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java index 3b27ed76ee14b1c8d1e5f0cf048d0918fc17ce48..80b82763c4805a5768bc7c1c3c822a3ceb75a838 100644 --- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java +++ b/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 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); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 06f66d637805a3f3b10dd8e7e7c846b8b3c29b6a..3d4b54e621fbd0ce3b7b1459f17ccafb4667aed9 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/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()); } diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index c27238838a13cfc72747032d9290d4f111b5aa22..25d334915d86710184c0d3742b1505b011556cb0 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/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> 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 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); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java index 4f9311392978e6d6980fb7d04b5f51ad51ba70af..290c52079d9a39476b56d63eb1bfb29f5fc72981 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java @@ -93,7 +93,7 @@ public class ListItemAdapter extends ArrayAdapter { final ImmutableList.Builder 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 { 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 { 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); diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java index e422aac15d50d658c8d7b7d822a6178c1985936f..caa0d3667bf930dcffda0ff3376db6b1efbb54b1 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/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 { 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 { 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 { 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 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 { protected ImageView contact_picture; protected TextView status_message; protected TextView encryption; + protected ChipGroup reactions; } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java index cb8e1d48da2eea7e4184252f101e8026156ba1a0..712b7ccb2fa1bec643de6554d64155d65ef91f92 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java @@ -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(); } diff --git a/src/main/res/drawable/ic_add_reaction_24dp.xml b/src/main/res/drawable/ic_add_reaction_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..f0f431b5b707b5d8908b7f20dd7d1edeb3e2dd47 --- /dev/null +++ b/src/main/res/drawable/ic_add_reaction_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/src/main/res/layout/dialog_add_reaction.xml b/src/main/res/layout/dialog_add_reaction.xml new file mode 100644 index 0000000000000000000000000000000000000000..4eda335e28bb81040dc78443985edd032887e113 --- /dev/null +++ b/src/main/res/layout/dialog_add_reaction.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/layout/item_emoji_button.xml b/src/main/res/layout/item_emoji_button.xml new file mode 100644 index 0000000000000000000000000000000000000000..0e1711f7c1af7bb335450f5921d117dcb15bad2b --- /dev/null +++ b/src/main/res/layout/item_emoji_button.xml @@ -0,0 +1,8 @@ + +