diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index 29c8db3f2..890bd4e51 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -79,10 +79,12 @@ import java.time.format.DateTimeParseException; import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.ListIterator; import java.util.Objects; +import java.util.Set; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.atomic.AtomicBoolean; @@ -518,6 +520,23 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return null; } + public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart) { + synchronized (this.messages) { + for (int i = this.messages.size() - 1; i >= 0; --i) { + final Message message = messages.get(i); + final Jid mcp = message.getCounterpart(); + if (mcp == null && counterpart != null) { + continue; + } + if (counterpart == null || mcp.equals(counterpart) || mcp.asBareJid().equals(counterpart)) { + final boolean idMatch = id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id) || (getMode() == MODE_MULTI && id.equals(message.getServerMsgId())); + if (idMatch) 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) { @@ -585,6 +604,40 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return false; } + public Message findMessageReactingTo(String id, Jid reactor) { + if (id == null) return null; + + synchronized (this.messages) { + for (int i = this.messages.size() - 1; i >= 0; --i) { + final Message message = messages.get(i); + if (reactor == null && message.getStatus() < Message.STATUS_SEND) continue; + if (reactor != null && message.getCounterpart() == null) continue; + if (reactor != null && !(message.getCounterpart().equals(reactor) || message.getCounterpart().asBareJid().equals(reactor))) continue; + + final Element r = message.getReactions(); + if (r != null && r.getAttribute("id") != null && id.equals(r.getAttribute("id"))) { + return message; + } + } + } + return null; + } + + public Set findReactionsTo(String id, Jid reactor) { + Set reactionEmoji = new HashSet<>(); + Message reactM = findMessageReactingTo(id, reactor); + Element reactions = reactM == null ? null : reactM.getReactions(); + if (reactions != null) { + for (Element el : reactions.getChildren()) { + if (el.getName().equals("reaction") && el.getNamespace().equals("urn:xmpp:reactions:0")) { + reactionEmoji.add(el.getContent()); + } + } + } + return reactionEmoji; + } + + public void populateWithMessages(final List messages) { if (historyPartMessages.size() > 0) { messages.clear(); diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index 04343bfaf..5a2e7b6c2 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -16,6 +16,7 @@ import java.io.IOException; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; @@ -362,6 +363,42 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable return m; } + public Message react(String emoji) { + Set emojis = new HashSet<>(); + if (conversation instanceof Conversation) emojis = ((Conversation) conversation).findReactionsTo(replyId(), null); + emojis.add(emoji); + final Message m = reply(); + m.appendBody(emoji); + final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reactions:0"); + fallback.addChild("body", "urn:xmpp:fallback:0"); + m.addPayload(fallback); + final Element reactions = new Element("reactions", "urn:xmpp:reactions:0").setAttribute("id", replyId()); + for (String oneEmoji : emojis) { + reactions.addChild("reaction", "urn:xmpp:reactions:0").setContent(oneEmoji); + } + m.addPayload(reactions); + return m; + } + + public void setReactions(Element reactions) { + if (this.payloads != null) { + this.payloads.remove(getReactions()); + } + addPayload(reactions); + } + + public Element getReactions() { + if (this.payloads == null) return null; + + for (Element el : this.payloads) { + if (el.getName().equals("reactions") && el.getNamespace().equals("urn:xmpp:reactions:0")) { + return el; + } + } + + return null; + } + public Element getReply() { if (this.payloads == null) return null; @@ -863,6 +900,31 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable return new ArrayList<>(this.payloads); } + public List getFallbacks(String... includeFor) { + List fallbacks = new ArrayList<>(); + + if (this.payloads == null) return fallbacks; + + for (Element el : this.payloads) { + if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) { + final String fallbackFor = el.getAttribute("for"); + if (fallbackFor == null) continue; + for (String includeOne : includeFor) { + if (fallbackFor.equals(includeOne)) { + fallbacks.add(el); + break; + } + } + } + } + + return fallbacks; + } + + public synchronized void clearFallbacks(String... includeFor) { + this.payloads.removeAll(getFallbacks(includeFor)); + } + public void setOob(boolean isOob) { this.oob = isOob; } diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index d3728531e..c3fcdbff1 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -410,13 +410,21 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece if (timestamp == null) { timestamp = AbstractParser.parseTimestamp(original, AbstractParser.parseTimestamp(packet)); } - final LocalizedContent body = packet.getBody(); + + LocalizedContent body = packet.getBody(); final Element mucUserElement = packet.findChild("x", Namespace.MUC_USER); final String pgpEncrypted = packet.findChildContent("x", "jabber:x:encrypted"); final Element replaceElement = packet.findChild("replace", "urn:xmpp:message-correct:0"); final Element oob = packet.findChild("x", Namespace.OOB); final String oobUrl = oob != null ? oob.findChildContent("url") : null; - final String replacementId = replaceElement == null ? null : replaceElement.getAttribute("id"); + String replacementId = replaceElement == null ? null : replaceElement.getAttribute("id"); + if (replacementId == null) { + Element fasten = packet.findChild("apply-to", "urn:xmpp:fasten:0"); + if (fasten != null && fasten.findChild("retract", "urn:xmpp:message-retract:0") != null) { + replacementId = fasten.getAttribute("id"); + packet.setBody(""); + } + } final Element axolotlEncrypted = packet.findChildEnsureSingle(XmppAxolotlMessage.CONTAINERTAG, AxolotlService.PEP_PREFIX); int status; final Jid counterpart; @@ -479,6 +487,27 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece nextCounterpart = counterpart; } + final Element reactions = packet.findChild("reactions", "urn:xmpp:reactions:0"); + if (body == null) { + if (reactions != null && reactions.getAttribute("id") != null) { + final Conversation conversation = mXmppConnectionService.find(account, counterpart.asBareJid(), nextCounterpart); + if (conversation != null) { + final Message reactionTo = conversation.findMessageWithRemoteIdAndCounterpart(reactions.getAttribute("id"), null); + if (reactionTo != null) { + String bodyS = reactionTo.reply().getBody(); + for (Element el : reactions.getChildren()) { + if (el.getName().equals("reaction") && el.getNamespace().equals("urn:xmpp:reactions:0")) { + bodyS += el.getContent(); + } + } + body = new LocalizedContent(bodyS, "en", 1); + final Message previousReaction = conversation.findMessageReactingTo(reactions.getAttribute("id"), counterpart); + if (previousReaction != null) replacementId = previousReaction.replyId(); + } + } + } + } + if (nextCounterpart != null && mXmppConnectionService.checkIsArchived(account, counterpart.asBareJid(), nextCounterpart)) { return; } @@ -605,7 +634,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } message.markable = packet.hasChild("markable", "urn:xmpp:chat-markers:0"); - + if (reactions != null) message.addPayload(reactions); for (Element el : packet.getChildren()) { String name = el.getName(); String ns = el.getNamespace(); diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 3aa11986f..da18976b5 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -1083,7 +1083,12 @@ public class XmppConnectionService extends Service { private void directReply(final Conversation conversation, final String body, final String lastMessageUuid, final boolean dismissAfterReply) {final Message inReplyTo = lastMessageUuid == null ? null : conversation.findMessageWithUuid(lastMessageUuid); Message message = new Message(conversation, body, conversation.getNextEncryption()); if (inReplyTo != null) { - message = inReplyTo.reply(); + if (Emoticons.isEmoji(body.toString().replaceAll("\\s", ""))) { + message = conversation.getReplyTo().react(body.toString().replaceAll("\\s", "")); + } else { + message = inReplyTo.reply(); + } + message.clearFallbacks("urn:xmpp:reply:0"); message.setBody(body); message.setEncryption(conversation.getNextEncryption()); } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 86121be70..2bfe26e2c 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -146,6 +146,7 @@ import eu.siacs.conversations.ui.widget.HighlighterView; import eu.siacs.conversations.ui.widget.TabLayout; import eu.siacs.conversations.utils.AccountUtils; import eu.siacs.conversations.utils.Compatibility; +import eu.siacs.conversations.utils.Emoticons; import eu.siacs.conversations.utils.GeoHelper; import eu.siacs.conversations.utils.MessageUtils; import eu.siacs.conversations.utils.NickValidityChecker; @@ -1046,8 +1047,12 @@ public class ConversationFragment extends XmppFragment final Message message; if (conversation.getCorrectingMessage() == null) { if (conversation.getReplyTo() != null) { - message = conversation.getReplyTo().reply(); - message.appendBody(body); + if (Emoticons.isEmoji(body.toString().replaceAll("\\s", ""))) { + message = conversation.getReplyTo().react(body.toString().replaceAll("\\s", "")); + } else { + message = conversation.getReplyTo().reply(); + message.appendBody(body); + } message.setEncryption(conversation.getNextEncryption()); } else { message = new Message(conversation, body, conversation.getNextEncryption()); @@ -1708,6 +1713,7 @@ public class ConversationFragment extends XmppFragment 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 retractMessage = menu.findItem(R.id.retract_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); @@ -1752,6 +1758,7 @@ public class ConversationFragment extends XmppFragment if (m.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED && !deleted) { retryDecryption.setVisible(true); } + if (!showError && m.getType() == Message.TYPE_TEXT && !m.isGeoUri() @@ -1759,6 +1766,24 @@ public class ConversationFragment extends XmppFragment && m.getConversation() instanceof Conversation) { correctMessage.setVisible(true); } + + if (!showError + && m.getType() == Message.TYPE_TEXT + && !m.isGeoUri() + && m.isLastCorrectableMessage() + && m.getConversation() instanceof Conversation) { + correctMessage.setVisible(true); + + if (!m.getBody().equals("") && !m.getBody().equals(" ")) { + retractMessage.setVisible(true); + } + } + + if (m.getReactions() != null) { + correctMessage.setVisible(false); + retractMessage.setVisible(true); + } + if ((m.isFileOrImage() && !deleted && !receiving) || (m.getType() == Message.TYPE_TEXT && !m.treatAsDownloadable()) && !unInitiatedButKnownSize @@ -1830,6 +1855,36 @@ public class ConversationFragment extends XmppFragment case R.id.correct_message: correctMessage(selectedMessage); return true; + case R.id.retract_message: + new AlertDialog.Builder(activity) + .setTitle(R.string.retract_message) + .setMessage(R.string.retract_message_alert_title) + .setPositiveButton(R.string.yes, (dialog, whichButton) -> { + Message message = selectedMessage; + + Element reactions = message.getReactions(); + if (reactions != null) { + final Message previousReaction = conversation.findMessageReactingTo(reactions.getAttribute("id"), null); + if (previousReaction != null) reactions = previousReaction.getReactions(); + for (Element el : reactions.getChildren()) { + if (message.getBody().endsWith(el.getContent())) { + reactions.removeChild(el); + } + } + message.setReactions(reactions); + if (previousReaction != null) { + previousReaction.setReactions(reactions); + activity.xmppConnectionService.updateMessage(previousReaction); + } + } + message.setBody(" "); + message.putEdited(message.getUuid(), message.getServerMsgId()); + message.setServerMsgId(null); + message.setUuid(UUID.randomUUID().toString()); + sendMessage(message); + }) + .setNegativeButton(R.string.no, null).show(); + return true; case R.id.copy_message: ShareUtil.copyToClipboard(activity, selectedMessage); return true; diff --git a/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java b/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java index 635afd145..eb440f513 100644 --- a/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java +++ b/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java @@ -14,7 +14,7 @@ public class LocalizedContent { public final String language; public final int count; - private LocalizedContent(String content, String language, int count) { + public LocalizedContent(String content, String language, int count) { this.content = content; this.language = language; this.count = count; @@ -23,14 +23,14 @@ public class LocalizedContent { public static LocalizedContent get(final Element element, String name) { final HashMap contents = new HashMap<>(); final String parentLanguage = element.getAttribute("xml:lang"); - for(Element child : element.children) { + for(Element child : element.getChildren()) { if (name.equals(child.getName())) { final String namespace = child.getNamespace(); final String childLanguage = child.getAttribute("xml:lang"); final String lang = childLanguage == null ? parentLanguage : childLanguage; final String content = child.getContent(); - if (content != null && (namespace == null || Namespace.JABBER_CLIENT.equals(namespace))) { - if (contents.put(lang, content) != null) { + if (namespace == null || "jabber:client".equals(namespace)) { + if (contents.put(lang, content == null ? "" : content) != null) { //anything that has multiple contents for the same language is invalid return null; } diff --git a/src/main/res/menu/message_context.xml b/src/main/res/menu/message_context.xml index 9e3a508d2..69786a17d 100644 --- a/src/main/res/menu/message_context.xml +++ b/src/main/res/menu/message_context.xml @@ -27,7 +27,7 @@ android:visible="false" /> + (No activated accounts) This field is required Correct message + Retract message Send corrected message You have already trusted this persons fingerprint. By selecting “Done” you are just confirming that %s is part of this group chat. You have disabled this account @@ -1072,4 +1073,5 @@ General %1$s (%2$d) Refresh Feature Discovery + Do you really want to retract this message?