From 1d65a9e93ad6eeed485c6a5e49d4387146daba27 Mon Sep 17 00:00:00 2001 From: ismael221 Date: Wed, 29 Jan 2025 19:58:17 -0300 Subject: [PATCH] Implement XEP-0444: Message Reactions in Smack This commit adds support for XEP-0444 (Message Reactions) in Smack. Key changes include: - Added ReactionsManager to handle reactions, including adding, removing, and listening for reactions on messages. - Introduced ReactionsElement and Reaction classes to represent the element and individual emoji reactions. - Added ReactionsFilter to detect messages containing reactions. - Implemented ReactionRestrictions to manage restrictions like max reactions per user and allowed emojis. - Integrated reaction restrictions with XMPP service discovery. - Added ReactionsListener for applications to handle incoming reactions. - Included unit tests to verify functionality. This enables emoji reactions in XMPP messages, with support for restrictions and service discovery. Related: XEP-0444 (https://xmpp.org/extensions/xep-0444.html) --- .../smackx/reactions/ReactionsListener.java | 31 ++ .../smackx/reactions/ReactionsManager.java | 357 ++++++++++++++++++ .../smackx/reactions/element/Reaction.java | 101 +++++ .../reactions/element/ReactionsElement.java | 119 ++++++ .../reactions/element/package-info.java | 20 + .../reactions/filter/ReactionsFilter.java | 39 ++ .../smackx/reactions/filter/package-info.java | 24 ++ .../smackx/reactions/package-info.java | 21 ++ .../reactions/provider/ReactionProvider.java | 35 ++ .../provider/ReactionsElementProvider.java | 52 +++ .../reactions/provider/package-info.java | 20 + .../smackx/reactions/ReactionTest.java | 164 ++++++++ 12 files changed, 983 insertions(+) create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/ReactionsListener.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/ReactionsManager.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/Reaction.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/ReactionsElement.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/package-info.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/filter/ReactionsFilter.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/filter/package-info.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/package-info.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/ReactionProvider.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/ReactionsElementProvider.java create mode 100644 smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/package-info.java create mode 100644 smack-experimental/src/test/java/org/jivesoftware/smackx/reactions/ReactionTest.java diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/ReactionsListener.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/ReactionsListener.java new file mode 100644 index 0000000000..2842212016 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/ReactionsListener.java @@ -0,0 +1,31 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.reactions; + +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smackx.reactions.element.ReactionsElement; + +public interface ReactionsListener { + + + /** + * Listener method that gets called when a {@link Message} containing a {@link ReactionsElement} is received. + * + * @param message message + */ + void onReactionReceived(Message message, ReactionsElement reactionsElement); +} diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/ReactionsManager.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/ReactionsManager.java new file mode 100644 index 0000000000..133b3bf7e6 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/ReactionsManager.java @@ -0,0 +1,357 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.reactions; + +import org.jivesoftware.smack.AsyncButOrdered; +import org.jivesoftware.smack.ConnectionCreationListener; +import org.jivesoftware.smack.Manager; +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.XMPPConnectionRegistry; +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.filter.AndFilter; +import org.jivesoftware.smack.filter.StanzaFilter; +import org.jivesoftware.smack.filter.StanzaTypeFilter; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.packet.XmlElement; +import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; +import org.jivesoftware.smackx.disco.packet.DiscoverInfo; +import org.jivesoftware.smackx.reactions.element.Reaction; +import org.jivesoftware.smackx.reactions.element.ReactionsElement; +import org.jivesoftware.smackx.reactions.filter.ReactionsFilter; +import org.jivesoftware.smackx.xdata.FormField; +import org.jivesoftware.smackx.xdata.TextSingleFormField; +import org.jivesoftware.smackx.xdata.form.Form; +import org.jivesoftware.smackx.xdata.packet.DataForm; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.stream.Collectors; + +import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.EntityBareJid; + +/** + * Manages reactions in the XMPP protocol. This class allows adding, removing, and listening for reactions + * on messages, as well as managing restrictions on the number of reactions per user and allowed emojis. + * It also allows propagating these restrictions to other clients via XMPP service discovery. + * + * This class is based on the XEP-0444 extension protocol for reactions. + * + * @author Ismael Nunes Campos + * + * @see XEP-0444 Message Reactions + * @see ReactionsElement + * @see Reaction + */ +public final class ReactionsManager extends Manager { + + private static final Map INSTANCES = new WeakHashMap<>(); + + + static { + XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() { + @Override + public void connectionCreated(XMPPConnection connection) { + getInstanceFor(connection); + } + }); + } + + private static final String REACTIONS_RESTRICTIONS_NAMESPACE = "urn:xmpp:reactions:0:restrictions"; + private final Set listeners = new CopyOnWriteArraySet<>(); + private final AsyncButOrdered asyncButOrdered = new AsyncButOrdered<>(); + private final StanzaFilter reactionsElementFilter = new AndFilter(StanzaTypeFilter.MESSAGE,ReactionsFilter.INSTANCE); + + + /** + * Constructs an instance of the reactions manager and add ReactionsElement to disco features. + * + * @param connection The XMPP connection used by the manager. + */ + public ReactionsManager(XMPPConnection connection) { + super(connection); + connection.addAsyncStanzaListener(this::reactionsElementListener,reactionsElementFilter); + ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection); + sdm.addFeature(ReactionsElement.NAMESPACE); + } + + /** + * Listener method for reactions elements in XMPP messages. This method is invoked when a new + * stanza (message) is received and attempts to extract a {@link ReactionsElement} from the message. + * If the element is found, it notifies the registered reaction listeners. + * + * @param packet The received XMPP stanza (message). + */ + public void reactionsElementListener(Stanza packet){ + Message message = (Message) packet; + ReactionsElement reactionsElement = ReactionsElement.fromMessage(message); + + if (reactionsElement != null){ + notifyReactionListeners(message,reactionsElement); + } + + } + + /** + * Notifies all registered reaction listeners that a new reaction has been received. This method + * performs the notification in an ordered, asynchronous manner to ensure listeners are notified in + * the order that they were added. + * + * @param message The XMPP message that contains the reactions. + * @param reactionsElement The {@link ReactionsElement} containing the reactions. + */ + public void notifyReactionListeners(Message message, ReactionsElement reactionsElement) { + for (ReactionsListener listener : listeners) { + asyncButOrdered.performAsyncButOrdered(message.getFrom().asBareJid(), () -> { + listener.onReactionReceived(message, reactionsElement); + }); + } + } + + + /** + * Retrieves the instance of the ReactionsManager for the given XMPP connection. + * + * @param connection The XMPP connection. + * @return The ReactionsManager instance for the connection. + */ + public static synchronized ReactionsManager getInstanceFor(XMPPConnection connection) { + ReactionsManager reactionsManager = INSTANCES.get(connection); + + if (reactionsManager == null) { + reactionsManager = new ReactionsManager(connection); + INSTANCES.put(connection, reactionsManager); + } + return reactionsManager; + } + + /** + * Checks whether the user supports reactions. + * + * @param jid The JID of the user. + * @return {@code true} if the user supports reactions, otherwise {@code false}. + * @throws XMPPException.XMPPErrorException If an XMPP error occurs. + * @throws SmackException.NotConnectedException If the connection is not established. + * @throws InterruptedException If the operation is interrupted. + * @throws SmackException.NoResponseException If no response is received from the server. + */ + public boolean userSupportsReactions(EntityBareJid jid) throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, + InterruptedException, SmackException.NoResponseException { + return ServiceDiscoveryManager.getInstanceFor(connection()).supportsFeature(jid,ReactionsElement.NAMESPACE); + } + + /** + * Checks whether the server supports reactions. + * + * @return {@code true} if the server supports reactions, otherwise {@code false}. + * @throws XMPPException.XMPPErrorException If an XMPP error occurs. + * @throws SmackException.NotConnectedException If the connection is not established. + * @throws InterruptedException If the operation is interrupted. + * @throws SmackException.NoResponseException If no response is received from the server. + */ + public boolean serverSupportsReactions() + throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, InterruptedException, + SmackException.NoResponseException { + return ServiceDiscoveryManager.getInstanceFor(connection()) + .serverSupportsFeature(ReactionsElement.NAMESPACE); + } + + /** + * Adds reactions to a message. + * + * @param message The message builder where the reactions will be added. + * @param emojis The list of emojis to be added as reactions. + * @param originalMessageId The ID of the original message being reacted to. + * @param restrictions The reaction restrictions such as max reactions per user and allowed emojis. + * @throws IllegalArgumentException If the reactions exceed the allowed limit or if any emoji is not allowed. + */ + public static void addReactionsToMessage(Message message, List emojis, + String originalMessageId, ReactionRestrictions restrictions){ + List reactions = new ArrayList<>(); + + if (restrictions != null) { + + if (emojis.size() > restrictions.getMaxReactionsPerUser()) { + throw new IllegalArgumentException("Exceeded maximum number of reactions per user"); + } + + + for (String emoji : emojis) { + if (!restrictions.getAllowedEmojis().contains(emoji)) { + throw new IllegalArgumentException("Emoji " + emoji + " is not allowed"); + } + } + } + + + for (String emoji : emojis) { + Reaction reaction = new Reaction(emoji); + reactions.add(reaction); + } + + ReactionsElement reactionsElement = new ReactionsElement(reactions, originalMessageId); + + message.addExtension(reactionsElement); + + } + + /** + * Adds a reactions' listener. + * + * @param listener The reactions listener to be added. + */ + public synchronized void addReactionsListener(ReactionsListener listener){ + listeners.add(listener); + } + + /** + * Removes a reactions listener. + * + * @param listener The reactions listener to be removed. + */ + public synchronized void removeReactionsListener(ReactionsListener listener){ + listeners.remove(listener); + } + + + /** + * Creates a form for reaction restrictions, including the max number of reactions per user + * and the list of allowed emojis. + * + * @param maxReactionsPerUser The maximum number of reactions allowed per user. + * @param allowedEmojis The list of allowed emojis. + * @return The reaction restrictions form. + */ + public static DataForm createReactionRestrictionsForm(int maxReactionsPerUser, List allowedEmojis) { + + DataForm.Builder builder = DataForm.builder(); + builder.setFormType(String.valueOf(DataForm.Type.result)); + builder.addField( + FormField.buildHiddenFormType("urn:xmpp:reactions:0:restrictions") + ); + builder.addField( + FormField.builder("max_reactions_per_user").setValue(String.valueOf(maxReactionsPerUser)) + .build() + ); + + FormField.Builder allowlistFieldBuilder = FormField.builder("allowlist"); + for (String emoji : allowedEmojis) { + Reaction reaction = new Reaction(emoji); + FormField.builder("value").setValue((CharSequence) reaction); + } + builder.addField(allowlistFieldBuilder.build()); + + return builder.build(); + } + + /** + * Advertises reaction restrictions to a given XMPP server. + * + * @param connection The XMPP connection. + * @param maxReactionsPerUser The maximum number of reactions allowed per user. + * @param allowedEmojis The list of allowed emojis. + */ + public void advertiseReactionRestrictions(XMPPConnection connection, int maxReactionsPerUser, List allowedEmojis) { + ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection); + DataForm restrictionsForm = createReactionRestrictionsForm(maxReactionsPerUser, allowedEmojis); + sdm.addExtendedInfo(restrictionsForm); + + sdm.addFeature(ReactionsElement.NAMESPACE); + } + + /** + * Retrieves the reaction restrictions for a given user. + * + * @param jid The JID of the user. + * @return The reaction restrictions for the user. + * @throws XMPPException.XMPPErrorException If an XMPP error occurs. + * @throws SmackException.NotConnectedException If the connection is not established. + * @throws InterruptedException If the operation is interrupted. + * @throws SmackException.NoResponseException If no response is received from the server. + */ + public ReactionRestrictions getReactionRestrictions(EntityBareJid jid) throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, + InterruptedException, SmackException.NoResponseException { + ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection()); + DiscoverInfo discoverInfo = sdm.discoverInfo(jid); + + + for (XmlElement extension : discoverInfo.getExtensions()) { + if (extension instanceof DataForm) { + DataForm dataForm = (DataForm) extension; + FormField formTypeField = dataForm.getField("FORM_TYPE"); + if (formTypeField != null && formTypeField.getValues().stream().anyMatch(v -> v.toString().equals(REACTIONS_RESTRICTIONS_NAMESPACE))) { + Form form = new Form(dataForm); + int maxReactionsPerUser = Integer.parseInt(form.getField("max_reactions_per_user").getFirstValue()); + + // Converts List to List + List allowedEmojis = form.getField("allowlist") + .getValues() + .stream() + .map(CharSequence::toString) + .collect(Collectors.toList()); + + return new ReactionRestrictions(maxReactionsPerUser, allowedEmojis); + } + } + } + return null; + } + + /** + * Represents the reaction restrictions for a user or XMPP server. + */ + public static class ReactionRestrictions { + private final int maxReactionsPerUser; + private final List allowedEmojis; + + /** + * Constructs the reaction restrictions. + * + * @param maxReactionsPerUser The maximum number of reactions allowed per user. + * @param allowedEmojis The list of allowed emojis. + */ + public ReactionRestrictions(int maxReactionsPerUser, List allowedEmojis) { + this.maxReactionsPerUser = maxReactionsPerUser; + this.allowedEmojis = allowedEmojis; + } + + /** + * Retrieves the maximum number of reactions allowed per user. + * + * @return The maximum number of reactions. + */ + public int getMaxReactionsPerUser() { + return maxReactionsPerUser; + } + + /** + * Retrieves the list of allowed emojis. + * + * @return The list of allowed emojis. + */ + public List getAllowedEmojis() { + return allowedEmojis; + } + } + +} diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/Reaction.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/Reaction.java new file mode 100644 index 0000000000..10b60daa13 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/Reaction.java @@ -0,0 +1,101 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.reactions.element; + +import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.XmlEnvironment; +import org.jivesoftware.smack.util.XmlStringBuilder; + +/** + * Represents a reaction in the form of an emoji to be associated with a message in XMPP. This class + * is used to handle the individual reaction data, including the emoji and its associated message. + * It is an extension element used in XMPP messages. + * + * @see ExtensionElement + * @see Message + */ +public class Reaction implements ExtensionElement { + + public static final String ELEMENT = "reaction"; + public static final String NAMESPACE = ""; + + private final String emoji; + + /** + * Constructs a new Reaction with the specified emoji. + * + * @param emoji The emoji representing the reaction. + */ + public Reaction(String emoji) { + this.emoji = emoji; + } + + /** + * Retrieves the emoji associated with this reaction. + * + * @return The emoji as a string. + */ + public String getEmoji() { + return emoji; + } + + /** + * Returns the namespace for this extension element. As the namespace is empty, it returns an empty string. + * + * @return The namespace of the reaction element, which is an empty string. + */ + @Override + public String getNamespace() { + return NAMESPACE; + } + + /** + * Returns the name of the XML element for this reaction, which is "reaction". + * + * @return The name of the XML element, which is "reaction". + */ + @Override + public String getElementName() { + return ELEMENT; + } + + /** + * Converts this Reaction into an XML representation that can be included in an XMPP message. + * + * @param xmlEnvironment The XML environment for serializing the element. + * @return The XML string builder containing the XML representation of the reaction element. + */ + @Override + public XmlStringBuilder toXML(XmlEnvironment xmlEnvironment) { + XmlStringBuilder xml = new XmlStringBuilder(this, xmlEnvironment); + xml.openElement(getElementName()); + xml.append(getEmoji()); + xml.closeElement(getElementName()); + return xml; + } + + /** + * Retrieves the Reaction extension from an XMPP message. + * + * @param message The XMPP message from which to extract the reaction. + * @return The Reaction extension from the message, or {@code null} if not present. + */ + public static Reaction fromMessage(Message message){ + return message.getExtension(Reaction.class); + } +} diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/ReactionsElement.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/ReactionsElement.java new file mode 100644 index 0000000000..f94d5c3075 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/ReactionsElement.java @@ -0,0 +1,119 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.reactions.element; + +import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.XmlEnvironment; +import org.jivesoftware.smack.util.XmlStringBuilder; + +import java.util.Collections; +import java.util.List; + +/** + * Represents the reactions element in an XMPP message. This class is used to manage and serialize + * the list of reactions associated with a message, including the reactions' emojis and their identifiers. + * It is used as an extension element in XMPP messages to allow reactions to be sent and received. + * + * @see Reaction + * @see Message + * @see ExtensionElement + */ +public class ReactionsElement implements ExtensionElement { + public static final String ELEMENT = "reactions"; + public static final String NAMESPACE = "urn:xmpp:reactions:0"; + + private final List reactions; + private final String id; + + /** + * Constructs a new ReactionsElement with a list of reactions and an identifier. + * + * @param reactions A list of reactions associated with a message. + * @param id The ID of the message being reacted to. + */ + public ReactionsElement(List reactions, String id) { + this.reactions = Collections.unmodifiableList(reactions); + this.id = id; + } + + /** + * Retrieves the list of reactions in this element. + * + * @return The list of reactions. + */ + public List getReactions() { + return reactions; + } + + /** + * Retrieves the ID of the original message being reacted to. + * + * @return The ID of the original message. + */ + public String getId() { + return id; + } + + + /** + * Returns the namespace for this extension element. + * + * @return The namespace of the reactions element. + */ + @Override + public String getNamespace() { + return NAMESPACE; + } + + /** + * Returns the name of the XML element associated with this extension. + * + * @return The element name for this extension, which is "reactions". + */ + @Override + public String getElementName() { + return ELEMENT; + } + + /** + * Converts this ReactionsElement into an XML representation that can be included in an XMPP message. + * + * @param xmlEnvironment The XML environment for serialization. + * @return The XML string builder with the XML representation of the element. + */ + @Override + public XmlStringBuilder toXML(XmlEnvironment xmlEnvironment) { + XmlStringBuilder xml = new XmlStringBuilder(this); + xml.attribute("id", id); + for (Reaction reaction : reactions) { + xml.append(reaction.toXML(xmlEnvironment)); + } + xml.closeElement(this); + return xml; + } + + /** + * Retrieves the ReactionsElement from an XMPP message. + * + * @param message The XMPP message from which the reactions element is extracted. + * @return The ReactionsElement from the message, or {@code null} if not present. + */ + public static ReactionsElement fromMessage(Message message){ + return message.getExtension(ReactionsElement.class); + } +} diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/package-info.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/package-info.java new file mode 100644 index 0000000000..fa922d697e --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/element/package-info.java @@ -0,0 +1,20 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Smacks implementation of XEP-0444: Message Reactions. + */ +package org.jivesoftware.smackx.reactions.element; \ No newline at end of file diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/filter/ReactionsFilter.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/filter/ReactionsFilter.java new file mode 100644 index 0000000000..cc242d5935 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/filter/ReactionsFilter.java @@ -0,0 +1,39 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.reactions.filter; + +import org.jivesoftware.smack.filter.StanzaExtensionFilter; +import org.jivesoftware.smack.filter.StanzaFilter; +import org.jivesoftware.smackx.reactions.element.ReactionsElement; + + +/** + * Message Reactions filter class. + * + * @see XEP-0444: Message + * Reactions + * @author Ismael Nunes Campos + * + */ +public final class ReactionsFilter extends StanzaExtensionFilter { + + public static final StanzaFilter INSTANCE = new ReactionsFilter(ReactionsElement.ELEMENT,ReactionsElement.NAMESPACE); + + private ReactionsFilter(String element,String namespace) { + super(element,namespace); + } +} diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/filter/package-info.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/filter/package-info.java new file mode 100644 index 0000000000..1a62d43583 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/filter/package-info.java @@ -0,0 +1,24 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Chat Markers elements (XEP-0444). + * + * @see XEP-0444: Message + * Reactions + * + */ +package org.jivesoftware.smackx.reactions.filter; \ No newline at end of file diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/package-info.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/package-info.java new file mode 100644 index 0000000000..ef2f14e936 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/package-info.java @@ -0,0 +1,21 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Smack's API for XEP-0444: Message Reactions. + */ +package org.jivesoftware.smackx.reactions; \ No newline at end of file diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/ReactionProvider.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/ReactionProvider.java new file mode 100644 index 0000000000..1ac24f91f4 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/ReactionProvider.java @@ -0,0 +1,35 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.reactions.provider; + +import org.jivesoftware.smack.packet.XmlEnvironment; +import org.jivesoftware.smack.parsing.SmackParsingException; +import org.jivesoftware.smack.provider.ExtensionElementProvider; +import org.jivesoftware.smack.xml.XmlPullParser; +import org.jivesoftware.smack.xml.XmlPullParserException; +import org.jivesoftware.smackx.reactions.element.Reaction; + +import java.io.IOException; +import java.text.ParseException; + +public class ReactionProvider extends ExtensionElementProvider { + @Override + public Reaction parse(XmlPullParser parser, int initialDepth, XmlEnvironment xmlEnvironment) throws XmlPullParserException, IOException, SmackParsingException, ParseException { + String emoji = parser.nextText(); + return new Reaction(emoji); + } +} diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/ReactionsElementProvider.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/ReactionsElementProvider.java new file mode 100644 index 0000000000..2944cd5695 --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/ReactionsElementProvider.java @@ -0,0 +1,52 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.reactions.provider; + +import org.jivesoftware.smack.packet.XmlEnvironment; +import org.jivesoftware.smack.parsing.SmackParsingException; +import org.jivesoftware.smack.provider.ExtensionElementProvider; +import org.jivesoftware.smack.xml.XmlPullParser; +import org.jivesoftware.smack.xml.XmlPullParserException; +import org.jivesoftware.smackx.reactions.element.Reaction; +import org.jivesoftware.smackx.reactions.element.ReactionsElement; + +import java.io.IOException; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.List; + +public class ReactionsElementProvider extends ExtensionElementProvider { + @Override + public ReactionsElement parse(XmlPullParser parser, int initialDepth, XmlEnvironment xmlEnvironment) throws XmlPullParserException, IOException, SmackParsingException, ParseException { + String id = parser.getAttributeValue(null, "id"); + List reactions = new ArrayList<>(); + + while (true) { + XmlPullParser.Event tag = parser.next(); + + if (tag == XmlPullParser.Event.END_ELEMENT && parser.getName().equals(ReactionsElement.ELEMENT)) { + break; + } + if (tag == XmlPullParser.Event.START_ELEMENT && parser.getName().equals(Reaction.ELEMENT)) { + String emoji = parser.nextText(); + reactions.add(new Reaction(emoji)); + } + } + + return new ReactionsElement(reactions, id); + } +} diff --git a/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/package-info.java b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/package-info.java new file mode 100644 index 0000000000..0f274dacce --- /dev/null +++ b/smack-experimental/src/main/java/org/jivesoftware/smackx/reactions/provider/package-info.java @@ -0,0 +1,20 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Smacks implementation of XEP-0444: Message Reactions. + */ +package org.jivesoftware.smackx.reactions.provider; \ No newline at end of file diff --git a/smack-experimental/src/test/java/org/jivesoftware/smackx/reactions/ReactionTest.java b/smack-experimental/src/test/java/org/jivesoftware/smackx/reactions/ReactionTest.java new file mode 100644 index 0000000000..9b0bb52946 --- /dev/null +++ b/smack-experimental/src/test/java/org/jivesoftware/smackx/reactions/ReactionTest.java @@ -0,0 +1,164 @@ +/** + * + * Copyright 2025 Ismael Nunes Campos + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.reactions; + +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.MessageBuilder; +import org.jivesoftware.smack.packet.StanzaBuilder; +import org.jivesoftware.smack.parsing.SmackParsingException; +import org.jivesoftware.smack.test.util.SmackTestSuite; +import org.jivesoftware.smack.test.util.TestUtils; +import org.jivesoftware.smack.xml.XmlPullParser; +import org.jivesoftware.smack.xml.XmlPullParserException; +import org.jivesoftware.smackx.reactions.element.Reaction; +import org.jivesoftware.smackx.reactions.element.ReactionsElement; +import org.jivesoftware.smackx.reactions.provider.ReactionsElementProvider; +import org.junit.jupiter.api.Test; +import org.jxmpp.jid.impl.JidCreate; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import static org.jivesoftware.smack.test.util.XmlAssertUtil.assertXmlSimilar; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Tests related to managing reactions in Smack XMPP. + * These tests cover the creation and manipulation of reaction elements, + * including adding reactions to messages, XML parsing, and verifying + * invalid restrictions. + * + * @author Ismael Nunes Campos + * @since 2025 + */ +public class ReactionTest extends SmackTestSuite { + + /** + * Tests parsing of a reactions XML and validates the extracted data. + * + * This test validates the creation of a `ReactionsElement` from an XML + * and checks if the message ID and emoji reactions are correctly extracted. + * + * @throws XmlPullParserException If an error occurs during XML parsing. + * @throws IOException If an I/O error occurs during parsing. + * @throws SmackParsingException If a failure occurs while parsing the XML. + */ + @Test + void testReaction() throws XmlPullParserException, IOException, SmackParsingException { + String xml = "" + + "👍" + + "❤️" + + ""; + + XmlPullParser parser = TestUtils.getParser(xml); + + ReactionsElementProvider provider = new ReactionsElementProvider(); + ReactionsElement reactionsElement = provider.parse(parser); + + assertEquals("msg-id-123", reactionsElement.getId()); + assertEquals(2, reactionsElement.getReactions().size()); + assertEquals("👍", reactionsElement.getReactions().get(0).getEmoji()); + assertEquals("❤️", reactionsElement.getReactions().get(1).getEmoji()); + } + + /** + * Tests adding reactions to a message. + * + * This test ensures that the `addReactionsToMessage` method correctly + * adds reactions to a message and that the reactions are correctly + * reflected in the message's extension elements. + * + * @throws Exception If an error occurs during message handling or assertions. + */ + @Test + public void testAddReactionsToMessage() throws Exception { + + List emojis = Arrays.asList("❤️", "❤️"); + String messageId = "1234"; + + MessageBuilder messageBuilder = StanzaBuilder.buildMessage(); + Message message = messageBuilder + .setBody("Hello") + .ofType(Message.Type.chat) + .to("teste@domain.com") + .build(); + + ReactionsManager.addReactionsToMessage(message, emojis, messageId, null); + + ReactionsElement reactionsElement = (ReactionsElement) message.getExtensionElement(ReactionsElement.ELEMENT, ReactionsElement.NAMESPACE); + + assertEquals(messageId, reactionsElement.getId()); + assertEquals(2, reactionsElement.getReactions().size()); + assertEquals("❤️", reactionsElement.getReactions().get(0).getEmoji()); + assertEquals("❤️", reactionsElement.getReactions().get(1).getEmoji()); + + } + + /** + * Tests the reactions element listener. + * + * This test simulates the receipt of a message with reactions and validates + * that the `reactionsElementListener` properly processes the reactions + * and adds the reactions extension to the message. + * + * @throws Exception If an error occurs during message handling or listener processing. + */ + @Test + public void testReactionsElementListener() throws Exception { + + // Define reactions + List reactions = Arrays.asList(new Reaction("😊"), new Reaction("😂")); + String messageId = "1234"; + + // Simulate a message with reactions + Message message = StanzaBuilder.buildMessage().build(); + ReactionsElement reactionsElement = new ReactionsElement(reactions, messageId); + message.addExtension(reactionsElement); + + // Act: Call the listener + XMPPConnection connection = mock(XMPPConnection.class); // Mock of XMPP connection + ReactionsManager reactionsManager = new ReactionsManager(connection); + reactionsManager.reactionsElementListener(message); + + // Assertions: Ensure that the message contains the reactions element + assertNotNull(message.getExtensionElement(ReactionsElement.ELEMENT, ReactionsElement.NAMESPACE)); + } + + /** + * Tests that an exception is thrown when invalid reaction restrictions are provided. + * + * This test checks that the system throws an `IllegalArgumentException` when + * attempting to create a reaction restriction form with invalid values (e.g., negative values). + * + * @throws Exception If an error occurs during the test execution. + */ + @Test + public void testInvalidReactionRestrictions() { + // Check invalid restrictions, like negative values + assertThrows(IllegalArgumentException.class, () -> { + ReactionsManager.createReactionRestrictionsForm(-1, Arrays.asList("😊", "😂")); + }); + } + + +}