Skip to content

Commit 1d65a9e

Browse files
committed
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 <reactions> 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)
1 parent 6fe95f4 commit 1d65a9e

File tree

12 files changed

+983
-0
lines changed

12 files changed

+983
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
*
3+
* Copyright 2025 Ismael Nunes Campos
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.jivesoftware.smackx.reactions;
18+
19+
import org.jivesoftware.smack.packet.Message;
20+
import org.jivesoftware.smackx.reactions.element.ReactionsElement;
21+
22+
public interface ReactionsListener {
23+
24+
25+
/**
26+
* Listener method that gets called when a {@link Message} containing a {@link ReactionsElement} is received.
27+
*
28+
* @param message message
29+
*/
30+
void onReactionReceived(Message message, ReactionsElement reactionsElement);
31+
}
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
/**
2+
*
3+
* Copyright 2025 Ismael Nunes Campos
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.jivesoftware.smackx.reactions;
18+
19+
import org.jivesoftware.smack.AsyncButOrdered;
20+
import org.jivesoftware.smack.ConnectionCreationListener;
21+
import org.jivesoftware.smack.Manager;
22+
import org.jivesoftware.smack.SmackException;
23+
import org.jivesoftware.smack.XMPPConnection;
24+
import org.jivesoftware.smack.XMPPConnectionRegistry;
25+
import org.jivesoftware.smack.XMPPException;
26+
import org.jivesoftware.smack.filter.AndFilter;
27+
import org.jivesoftware.smack.filter.StanzaFilter;
28+
import org.jivesoftware.smack.filter.StanzaTypeFilter;
29+
import org.jivesoftware.smack.packet.Message;
30+
import org.jivesoftware.smack.packet.Stanza;
31+
import org.jivesoftware.smack.packet.XmlElement;
32+
import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
33+
import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
34+
import org.jivesoftware.smackx.reactions.element.Reaction;
35+
import org.jivesoftware.smackx.reactions.element.ReactionsElement;
36+
import org.jivesoftware.smackx.reactions.filter.ReactionsFilter;
37+
import org.jivesoftware.smackx.xdata.FormField;
38+
import org.jivesoftware.smackx.xdata.TextSingleFormField;
39+
import org.jivesoftware.smackx.xdata.form.Form;
40+
import org.jivesoftware.smackx.xdata.packet.DataForm;
41+
42+
import java.util.ArrayList;
43+
import java.util.List;
44+
import java.util.Map;
45+
import java.util.Set;
46+
import java.util.WeakHashMap;
47+
import java.util.concurrent.CopyOnWriteArraySet;
48+
import java.util.stream.Collectors;
49+
50+
import org.jxmpp.jid.BareJid;
51+
import org.jxmpp.jid.EntityBareJid;
52+
53+
/**
54+
* Manages reactions in the XMPP protocol. This class allows adding, removing, and listening for reactions
55+
* on messages, as well as managing restrictions on the number of reactions per user and allowed emojis.
56+
* It also allows propagating these restrictions to other clients via XMPP service discovery.
57+
*
58+
* This class is based on the XEP-0444 extension protocol for reactions.
59+
*
60+
* @author Ismael Nunes Campos
61+
*
62+
* @see <a href="https://xmpp.org/extensions/xep-0444.html">XEP-0444 Message Reactions</a>
63+
* @see ReactionsElement
64+
* @see Reaction
65+
*/
66+
public final class ReactionsManager extends Manager {
67+
68+
private static final Map<XMPPConnection, ReactionsManager> INSTANCES = new WeakHashMap<>();
69+
70+
71+
static {
72+
XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
73+
@Override
74+
public void connectionCreated(XMPPConnection connection) {
75+
getInstanceFor(connection);
76+
}
77+
});
78+
}
79+
80+
private static final String REACTIONS_RESTRICTIONS_NAMESPACE = "urn:xmpp:reactions:0:restrictions";
81+
private final Set<ReactionsListener> listeners = new CopyOnWriteArraySet<>();
82+
private final AsyncButOrdered<BareJid> asyncButOrdered = new AsyncButOrdered<>();
83+
private final StanzaFilter reactionsElementFilter = new AndFilter(StanzaTypeFilter.MESSAGE,ReactionsFilter.INSTANCE);
84+
85+
86+
/**
87+
* Constructs an instance of the reactions manager and add ReactionsElement to disco features.
88+
*
89+
* @param connection The XMPP connection used by the manager.
90+
*/
91+
public ReactionsManager(XMPPConnection connection) {
92+
super(connection);
93+
connection.addAsyncStanzaListener(this::reactionsElementListener,reactionsElementFilter);
94+
ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection);
95+
sdm.addFeature(ReactionsElement.NAMESPACE);
96+
}
97+
98+
/**
99+
* Listener method for reactions elements in XMPP messages. This method is invoked when a new
100+
* stanza (message) is received and attempts to extract a {@link ReactionsElement} from the message.
101+
* If the element is found, it notifies the registered reaction listeners.
102+
*
103+
* @param packet The received XMPP stanza (message).
104+
*/
105+
public void reactionsElementListener(Stanza packet){
106+
Message message = (Message) packet;
107+
ReactionsElement reactionsElement = ReactionsElement.fromMessage(message);
108+
109+
if (reactionsElement != null){
110+
notifyReactionListeners(message,reactionsElement);
111+
}
112+
113+
}
114+
115+
/**
116+
* Notifies all registered reaction listeners that a new reaction has been received. This method
117+
* performs the notification in an ordered, asynchronous manner to ensure listeners are notified in
118+
* the order that they were added.
119+
*
120+
* @param message The XMPP message that contains the reactions.
121+
* @param reactionsElement The {@link ReactionsElement} containing the reactions.
122+
*/
123+
public void notifyReactionListeners(Message message, ReactionsElement reactionsElement) {
124+
for (ReactionsListener listener : listeners) {
125+
asyncButOrdered.performAsyncButOrdered(message.getFrom().asBareJid(), () -> {
126+
listener.onReactionReceived(message, reactionsElement);
127+
});
128+
}
129+
}
130+
131+
132+
/**
133+
* Retrieves the instance of the ReactionsManager for the given XMPP connection.
134+
*
135+
* @param connection The XMPP connection.
136+
* @return The ReactionsManager instance for the connection.
137+
*/
138+
public static synchronized ReactionsManager getInstanceFor(XMPPConnection connection) {
139+
ReactionsManager reactionsManager = INSTANCES.get(connection);
140+
141+
if (reactionsManager == null) {
142+
reactionsManager = new ReactionsManager(connection);
143+
INSTANCES.put(connection, reactionsManager);
144+
}
145+
return reactionsManager;
146+
}
147+
148+
/**
149+
* Checks whether the user supports reactions.
150+
*
151+
* @param jid The JID of the user.
152+
* @return {@code true} if the user supports reactions, otherwise {@code false}.
153+
* @throws XMPPException.XMPPErrorException If an XMPP error occurs.
154+
* @throws SmackException.NotConnectedException If the connection is not established.
155+
* @throws InterruptedException If the operation is interrupted.
156+
* @throws SmackException.NoResponseException If no response is received from the server.
157+
*/
158+
public boolean userSupportsReactions(EntityBareJid jid) throws XMPPException.XMPPErrorException, SmackException.NotConnectedException,
159+
InterruptedException, SmackException.NoResponseException {
160+
return ServiceDiscoveryManager.getInstanceFor(connection()).supportsFeature(jid,ReactionsElement.NAMESPACE);
161+
}
162+
163+
/**
164+
* Checks whether the server supports reactions.
165+
*
166+
* @return {@code true} if the server supports reactions, otherwise {@code false}.
167+
* @throws XMPPException.XMPPErrorException If an XMPP error occurs.
168+
* @throws SmackException.NotConnectedException If the connection is not established.
169+
* @throws InterruptedException If the operation is interrupted.
170+
* @throws SmackException.NoResponseException If no response is received from the server.
171+
*/
172+
public boolean serverSupportsReactions()
173+
throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, InterruptedException,
174+
SmackException.NoResponseException {
175+
return ServiceDiscoveryManager.getInstanceFor(connection())
176+
.serverSupportsFeature(ReactionsElement.NAMESPACE);
177+
}
178+
179+
/**
180+
* Adds reactions to a message.
181+
*
182+
* @param message The message builder where the reactions will be added.
183+
* @param emojis The list of emojis to be added as reactions.
184+
* @param originalMessageId The ID of the original message being reacted to.
185+
* @param restrictions The reaction restrictions such as max reactions per user and allowed emojis.
186+
* @throws IllegalArgumentException If the reactions exceed the allowed limit or if any emoji is not allowed.
187+
*/
188+
public static void addReactionsToMessage(Message message, List<String> emojis,
189+
String originalMessageId, ReactionRestrictions restrictions){
190+
List<Reaction> reactions = new ArrayList<>();
191+
192+
if (restrictions != null) {
193+
194+
if (emojis.size() > restrictions.getMaxReactionsPerUser()) {
195+
throw new IllegalArgumentException("Exceeded maximum number of reactions per user");
196+
}
197+
198+
199+
for (String emoji : emojis) {
200+
if (!restrictions.getAllowedEmojis().contains(emoji)) {
201+
throw new IllegalArgumentException("Emoji " + emoji + " is not allowed");
202+
}
203+
}
204+
}
205+
206+
207+
for (String emoji : emojis) {
208+
Reaction reaction = new Reaction(emoji);
209+
reactions.add(reaction);
210+
}
211+
212+
ReactionsElement reactionsElement = new ReactionsElement(reactions, originalMessageId);
213+
214+
message.addExtension(reactionsElement);
215+
216+
}
217+
218+
/**
219+
* Adds a reactions' listener.
220+
*
221+
* @param listener The reactions listener to be added.
222+
*/
223+
public synchronized void addReactionsListener(ReactionsListener listener){
224+
listeners.add(listener);
225+
}
226+
227+
/**
228+
* Removes a reactions listener.
229+
*
230+
* @param listener The reactions listener to be removed.
231+
*/
232+
public synchronized void removeReactionsListener(ReactionsListener listener){
233+
listeners.remove(listener);
234+
}
235+
236+
237+
/**
238+
* Creates a form for reaction restrictions, including the max number of reactions per user
239+
* and the list of allowed emojis.
240+
*
241+
* @param maxReactionsPerUser The maximum number of reactions allowed per user.
242+
* @param allowedEmojis The list of allowed emojis.
243+
* @return The reaction restrictions form.
244+
*/
245+
public static DataForm createReactionRestrictionsForm(int maxReactionsPerUser, List<String> allowedEmojis) {
246+
247+
DataForm.Builder builder = DataForm.builder();
248+
builder.setFormType(String.valueOf(DataForm.Type.result));
249+
builder.addField(
250+
FormField.buildHiddenFormType("urn:xmpp:reactions:0:restrictions")
251+
);
252+
builder.addField(
253+
FormField.builder("max_reactions_per_user").setValue(String.valueOf(maxReactionsPerUser))
254+
.build()
255+
);
256+
257+
FormField.Builder<TextSingleFormField, TextSingleFormField.Builder> allowlistFieldBuilder = FormField.builder("allowlist");
258+
for (String emoji : allowedEmojis) {
259+
Reaction reaction = new Reaction(emoji);
260+
FormField.builder("value").setValue((CharSequence) reaction);
261+
}
262+
builder.addField(allowlistFieldBuilder.build());
263+
264+
return builder.build();
265+
}
266+
267+
/**
268+
* Advertises reaction restrictions to a given XMPP server.
269+
*
270+
* @param connection The XMPP connection.
271+
* @param maxReactionsPerUser The maximum number of reactions allowed per user.
272+
* @param allowedEmojis The list of allowed emojis.
273+
*/
274+
public void advertiseReactionRestrictions(XMPPConnection connection, int maxReactionsPerUser, List<String> allowedEmojis) {
275+
ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection);
276+
DataForm restrictionsForm = createReactionRestrictionsForm(maxReactionsPerUser, allowedEmojis);
277+
sdm.addExtendedInfo(restrictionsForm);
278+
279+
sdm.addFeature(ReactionsElement.NAMESPACE);
280+
}
281+
282+
/**
283+
* Retrieves the reaction restrictions for a given user.
284+
*
285+
* @param jid The JID of the user.
286+
* @return The reaction restrictions for the user.
287+
* @throws XMPPException.XMPPErrorException If an XMPP error occurs.
288+
* @throws SmackException.NotConnectedException If the connection is not established.
289+
* @throws InterruptedException If the operation is interrupted.
290+
* @throws SmackException.NoResponseException If no response is received from the server.
291+
*/
292+
public ReactionRestrictions getReactionRestrictions(EntityBareJid jid) throws XMPPException.XMPPErrorException, SmackException.NotConnectedException,
293+
InterruptedException, SmackException.NoResponseException {
294+
ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection());
295+
DiscoverInfo discoverInfo = sdm.discoverInfo(jid);
296+
297+
298+
for (XmlElement extension : discoverInfo.getExtensions()) {
299+
if (extension instanceof DataForm) {
300+
DataForm dataForm = (DataForm) extension;
301+
FormField formTypeField = dataForm.getField("FORM_TYPE");
302+
if (formTypeField != null && formTypeField.getValues().stream().anyMatch(v -> v.toString().equals(REACTIONS_RESTRICTIONS_NAMESPACE))) {
303+
Form form = new Form(dataForm);
304+
int maxReactionsPerUser = Integer.parseInt(form.getField("max_reactions_per_user").getFirstValue());
305+
306+
// Converts List<? extends CharSequence> to List<String>
307+
List<String> allowedEmojis = form.getField("allowlist")
308+
.getValues()
309+
.stream()
310+
.map(CharSequence::toString)
311+
.collect(Collectors.toList());
312+
313+
return new ReactionRestrictions(maxReactionsPerUser, allowedEmojis);
314+
}
315+
}
316+
}
317+
return null;
318+
}
319+
320+
/**
321+
* Represents the reaction restrictions for a user or XMPP server.
322+
*/
323+
public static class ReactionRestrictions {
324+
private final int maxReactionsPerUser;
325+
private final List<String> allowedEmojis;
326+
327+
/**
328+
* Constructs the reaction restrictions.
329+
*
330+
* @param maxReactionsPerUser The maximum number of reactions allowed per user.
331+
* @param allowedEmojis The list of allowed emojis.
332+
*/
333+
public ReactionRestrictions(int maxReactionsPerUser, List<String> allowedEmojis) {
334+
this.maxReactionsPerUser = maxReactionsPerUser;
335+
this.allowedEmojis = allowedEmojis;
336+
}
337+
338+
/**
339+
* Retrieves the maximum number of reactions allowed per user.
340+
*
341+
* @return The maximum number of reactions.
342+
*/
343+
public int getMaxReactionsPerUser() {
344+
return maxReactionsPerUser;
345+
}
346+
347+
/**
348+
* Retrieves the list of allowed emojis.
349+
*
350+
* @return The list of allowed emojis.
351+
*/
352+
public List<String> getAllowedEmojis() {
353+
return allowedEmojis;
354+
}
355+
}
356+
357+
}

0 commit comments

Comments
 (0)