git » sdk » commit 961a7ce

Replies and reactions

author Stephen Paul Weber
2023-12-19 03:04:22 UTC
committer Stephen Paul Weber
2023-12-19 03:45:32 UTC
parent 600064d08dde5ca22a188d14992ed592a4a1391a

Replies and reactions

Parsing a message stanza may not lead to a chat message, so we have a
helper to get the different things it may be now.

xmpp/Chat.hx +135 -29
xmpp/ChatMessage.hx +76 -151
xmpp/Client.hx +31 -22
xmpp/EmojiUtil.hx +258 -0
xmpp/Message.hx +196 -0
xmpp/MessageSync.hx +11 -9
xmpp/Persistence.hx +4 -3
xmpp/ReactionUpdate.hx +37 -0
xmpp/persistence/browser.js +138 -57

diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx
index d478041..a4d7bfa 100644
--- a/xmpp/Chat.hx
+++ b/xmpp/Chat.hx
@@ -241,6 +241,12 @@ abstract class Chat {
 			return EventUnhandled; // Allow others to get this event as well
 		});
 	}
+
+	public function addReaction(m:ChatMessage, reaction:String) {
+		final toSend = m.reply();
+		toSend.text = reaction;
+		sendMessage(toSend);
+	}
 }
 
 @:expose
@@ -262,11 +268,20 @@ class DirectChat extends Chat {
 				var filter:MAMQueryParams = { with: this.chatId };
 				if (beforeId != null) filter.page = { before: beforeId };
 				var sync = new MessageSync(this.client, this.stream, filter);
-				sync.onMessages((messages) -> {
-					for (message in messages.messages) {
-						persistence.storeMessage(client.accountId(), message);
+				sync.onMessages((messageList) -> {
+					final chatMessages = [];
+					for (m in messageList.messages) {
+						switch (m) {
+							case ChatMessageStanza(message):
+								persistence.storeMessage(client.accountId(), message, (m)->{});
+								if (message.chatId() == chatId) chatMessages.push(message);
+							case ReactionUpdateStanza(update):
+								persistence.storeReaction(client.accountId(), update, (m)->{});
+							default:
+								// ignore
+						}
 					}
-					handler(messages.messages.filter((m) -> m.chatId() == chatId));
+					handler(chatMessages);
 				});
 				sync.fetchNext();
 			}
@@ -289,12 +304,15 @@ class DirectChat extends Chat {
 	}
 
 	public function correctMessage(localId:String, message:ChatMessage) {
+		final toSend = message.clone();
 		message = prepareOutgoingMessage(message);
-		persistence.correctMessage(client.accountId(), localId, message, (corrected) -> {
-			message.versions = corrected.versions;
+		message.versions = [toSend]; // This is a correction
+		message.localId = localId;
+		persistence.storeMessage(client.accountId(), message, (corrected) -> {
+			toSend.versions = corrected.versions;
 			for (recipient in message.recipients) {
 				message.to = recipient;
-				client.sendStanza(message.asStanza());
+				client.sendStanza(toSend.asStanza());
 			}
 			if (localId == lastMessage?.localId) {
 				setLastMessage(corrected);
@@ -307,13 +325,47 @@ class DirectChat extends Chat {
 	public function sendMessage(message:ChatMessage):Void {
 		client.chatActivity(this);
 		message = prepareOutgoingMessage(message);
-		persistence.storeMessage(client.accountId(), message);
-		for (recipient in message.recipients) {
-			message.to = recipient;
-			client.sendStanza(message.asStanza());
+		final fromStanza = Message.fromStanza(message.asStanza(), client.jid);
+		switch (fromStanza) {
+			case ChatMessageStanza(_):
+				persistence.storeMessage(client.accountId(), message, (stored) -> {
+					for (recipient in message.recipients) {
+						message.to = recipient;
+						client.sendStanza(message.asStanza());
+					}
+					setLastMessage(message);
+					client.trigger("chats/update", [this]);
+					client.notifyMessageHandlers(stored);
+				});
+			case ReactionUpdateStanza(update):
+				persistence.storeReaction(client.accountId(), update, (stored) -> {
+					for (recipient in message.recipients) {
+						message.to = recipient;
+						client.sendStanza(message.asStanza());
+					}
+					if (stored != null) client.notifyMessageHandlers(stored);
+				});
+			default:
+				trace("Invalid message", fromStanza);
+				throw "Trying to send invalid message.";
 		}
-		setLastMessage(message);
-		client.trigger("chats/update", [this]);
+	}
+
+	public function removeReaction(m:ChatMessage, reaction:String) {
+		// NOTE: doing it this way means no fallback behaviour
+		final reactions = [];
+		for (areaction => senders in m.reactions) {
+			if (areaction != reaction && senders.contains(client.accountId())) reactions.push(areaction);
+		}
+		final update = new ReactionUpdate(ID.long(), null, m.localId, m.chatId(), Date.format(std.Date.now()), client.accountId(), reactions);
+		persistence.storeReaction(client.accountId(), update, (stored) -> {
+			final stanza = update.asStanza();
+			for (recipient in getParticipants()) {
+				stanza.attr.set("to", recipient);
+				client.sendStanza(stanza);
+			}
+			if (stored != null) client.notifyMessageHandlers(stored);
+		});
 	}
 
 	public function lastMessageId() {
@@ -446,15 +498,24 @@ class Channel extends Chat {
 			chatId
 		);
 		sync.setNewestPageFirst(false);
+		final chatMessages = [];
 		sync.onMessages((messageList) -> {
-			for (message in messageList.messages) {
-				persistence.storeMessage(client.accountId(), message);
+			for (m in messageList.messages) {
+				switch (m) {
+					case ChatMessageStanza(message):
+						persistence.storeMessage(client.accountId(), message, (m)->{});
+						if (message.chatId() == chatId) chatMessages.push(message);
+					case ReactionUpdateStanza(update):
+						persistence.storeReaction(client.accountId(), update, (m)->{});
+					default:
+						// ignore
+				}
 			}
 			if (sync.hasMore()) {
 				sync.fetchNext();
 			} else {
 				inSync = true;
-				final lastFromSync = messageList.messages[messageList.messages.length - 1];
+				final lastFromSync = chatMessages[chatMessages.length - 1];
 				if (lastFromSync != null && Reflect.compare(lastFromSync.timestamp, lastMessageTimestamp()) > 0) {
 					setLastMessage(lastFromSync);
 					client.trigger("chats/update", [this]);
@@ -540,12 +601,21 @@ class Channel extends Chat {
 				var filter:MAMQueryParams = {};
 				if (beforeId != null) filter.page = { before: beforeId };
 				var sync = new MessageSync(this.client, this.stream, filter, chatId);
-				sync.onMessages((messages) -> {
-					for (message in messages.messages) {
-						message = prepareIncomingMessage(message, new Stanza("message", { from: message.senderId() }));
-						persistence.storeMessage(client.accountId(), message);
+				sync.onMessages((messageList) -> {
+					final chatMessages = [];
+					for (m in messageList.messages) {
+						switch (m) {
+							case ChatMessageStanza(message):
+								final chatMessage = prepareIncomingMessage(message, new Stanza("message", { from: message.senderId() }));
+								persistence.storeMessage(client.accountId(), chatMessage, (m)->{});
+								if (message.chatId() == chatId) chatMessages.push(message);
+							case ReactionUpdateStanza(update):
+								persistence.storeReaction(client.accountId(), update, (m)->{});
+							default:
+								// ignore
+						}
 					}
-					handler(messages.messages.filter((m) -> m.chatId() == chatId));
+					handler(chatMessages);
 				});
 				sync.fetchNext();
 			}
@@ -553,7 +623,6 @@ class Channel extends Chat {
 	}
 
 	public function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) {
-		// TODO: mark type!=groupchat as whisper somehow
 		message.syncPoint = inSync;
 		message.sender = JID.parse(stanza.attr.get("from")); // MUC always needs full JIDs
 		if (message.senderId() == getFullJid().asString()) {
@@ -564,6 +633,7 @@ class Channel extends Chat {
 	}
 
 	private function prepareOutgoingMessage(message:ChatMessage) {
+		message.groupchat = true;
 		message.timestamp = message.timestamp ?? Date.format(std.Date.now());
 		message.direction = MessageSent;
 		message.from = client.jid;
@@ -575,10 +645,13 @@ class Channel extends Chat {
 	}
 
 	public function correctMessage(localId:String, message:ChatMessage) {
+		final toSend = message.clone();
 		message = prepareOutgoingMessage(message);
-		persistence.correctMessage(client.accountId(), localId, message, (corrected) -> {
-			message.versions = corrected.versions;
-			client.sendStanza(message.asStanza("groupchat"));
+		message.versions = [toSend]; // This is a correction
+		message.localId = localId;
+		persistence.storeMessage(client.accountId(), message, (corrected) -> {
+			toSend.versions = corrected.versions;
+			client.sendStanza(toSend.asStanza());
 			if (localId == lastMessage?.localId) {
 				setLastMessage(corrected);
 				client.trigger("chats/update", [this]);
@@ -590,10 +663,43 @@ class Channel extends Chat {
 	public function sendMessage(message:ChatMessage):Void {
 		client.chatActivity(this);
 		message = prepareOutgoingMessage(message);
-		persistence.storeMessage(client.accountId(), message);
-		client.sendStanza(message.asStanza("groupchat"));
-		setLastMessage(message);
-		client.trigger("chats/update", [this]);
+		final stanza = message.asStanza();
+		// Fake from as it will look on reflection for storage purposes
+		stanza.attr.set("from", getFullJid().asString());
+		final fromStanza = Message.fromStanza(stanza, client.jid);
+		stanza.attr.set("from", client.jid.asString());
+		switch (fromStanza) {
+			case ChatMessageStanza(_):
+				persistence.storeMessage(client.accountId(), message, (stored) -> {
+					client.sendStanza(stanza);
+					setLastMessage(stored);
+					client.trigger("chats/update", [this]);
+					client.notifyMessageHandlers(stored);
+				});
+			case ReactionUpdateStanza(update):
+				persistence.storeReaction(client.accountId(), update, (stored) -> {
+					client.sendStanza(stanza);
+					if (stored != null) client.notifyMessageHandlers(stored);
+				});
+			default:
+				trace("Invalid message", fromStanza);
+				throw "Trying to send invalid message.";
+		}
+	}
+
+	public function removeReaction(m:ChatMessage, reaction:String) {
+		// NOTE: doing it this way means no fallback behaviour
+		final reactions = [];
+		for (areaction => senders in m.reactions) {
+			if (areaction != reaction && senders.contains(getFullJid().asString())) reactions.push(areaction);
+		}
+		final update = new ReactionUpdate(ID.long(), m.serverId, null, m.chatId(), Date.format(std.Date.now()), client.accountId(), reactions);
+		persistence.storeReaction(client.accountId(), update, (stored) -> {
+			final stanza = update.asStanza();
+			stanza.attr.set("to", chatId);
+			client.sendStanza(stanza);
+			if (stored != null) client.notifyMessageHandlers(stored);
+		});
 	}
 
 	public function lastMessageId() {
diff --git a/xmpp/ChatMessage.hx b/xmpp/ChatMessage.hx
index 0d7524f..9b6fe07 100644
--- a/xmpp/ChatMessage.hx
+++ b/xmpp/ChatMessage.hx
@@ -10,18 +10,8 @@ import xmpp.JID;
 import xmpp.Identicon;
 import xmpp.StringUtil;
 import xmpp.XEP0393;
-
-enum MessageDirection {
-	MessageReceived;
-	MessageSent;
-}
-
-enum MessageStatus {
-	MessagePending; // Message is waiting in client for sending
-	MessageDeliveredToServer; // Server acknowledged receipt of the message
-	MessageDeliveredToDevice; //The message has been delivered to at least one client device
-	MessageFailedToSend; // There was an error sending this message
-}
+import xmpp.EmojiUtil;
+import xmpp.Message;
 
 class ChatAttachment {
 	public final name: Null<String>;
@@ -55,13 +45,16 @@ class ChatMessage {
 	public var recipients: Array<JID> = [];
 	public var replyTo: Array<JID> = [];
 
-	public var threadId (default, null): Null<String> = null;
+	public var replyToMessage: Null<ChatMessage> = null;
+	public var threadId: Null<String> = null;
 
-	public var attachments : Array<ChatAttachment> = [];
+	public var attachments: Array<ChatAttachment> = [];
+	public var reactions: Map<String, Array<String>> = [];
 
-	public var text (default, null): Null<String> = null;
-	public var lang (default, null): Null<String> = null;
+	public var text: Null<String> = null;
+	public var lang: Null<String> = null;
 
+	public var groupchat: Bool = false; // Only really useful for distinguishing whispers
 	public var direction: MessageDirection = MessageReceived;
 	public var status: MessageStatus = MessagePending;
 	public var versions: Array<ChatMessage> = [];
@@ -70,129 +63,12 @@ class ChatMessage {
 	public function new() { }
 
 	public static function fromStanza(stanza:Stanza, localJid:JID):Null<ChatMessage> {
-		if (stanza.attr.get("type") == "error") return null;
-
-		var msg = new ChatMessage();
-		msg.timestamp = stanza.findText("{urn:xmpp:delay}delay@stamp") ?? Date.format(std.Date.now());
-		msg.threadId = stanza.getChildText("thread");
-		msg.lang = stanza.attr.get("xml:lang");
-		msg.text = stanza.getChildText("body");
-		if (msg.text != null && (msg.lang == null || msg.lang == "")) {
-			msg.lang = stanza.getChild("body")?.attr.get("xml:lang");
-		}
-		final from = stanza.attr.get("from");
-		msg.from = from == null ? null : JID.parse(from);
-		msg.sender = stanza.attr.get("type") == "groupchat" ? msg.from : msg.from?.asBare();
-		final localJidBare = localJid.asBare();
-		final domain = localJid.domain;
-		final to = stanza.attr.get("to");
-		msg.to = to == null ? localJid : JID.parse(to);
-
-		if (msg.from != null && msg.from.equals(localJidBare)) {
-			var carbon = stanza.getChild("received", "urn:xmpp:carbons:2");
-			if (carbon == null) carbon = stanza.getChild("sent", "urn:xmpp:carbons:2");
-			if (carbon != null) {
-				var fwd = carbon.getChild("forwarded", "urn:xmpp:forward:0");
-				if(fwd != null) return fromStanza(fwd.getFirstChild(), localJid);
-			}
-		}
-
-		final localId = stanza.attr.get("id");
-		if (localId != null) msg.localId = localId;
-		var altServerId = null;
-		for (stanzaId in stanza.allTags("stanza-id", "urn:xmpp:sid:0")) {
-			final id = stanzaId.attr.get("id");
-			if ((stanzaId.attr.get("by") == domain || stanzaId.attr.get("by") == localJidBare.asString()) && id != null) {
-				msg.serverIdBy = localJidBare.asString();
-				msg.serverId = id;
-				break;
-			}
-			altServerId = stanzaId;
-		}
-		if (msg.serverId == null && altServerId != null && stanza.attr.get("type") != "error") {
-			final id = altServerId.attr.get("id");
-			if (id != null) {
-				msg.serverId = id;
-				msg.serverIdBy = altServerId.attr.get("by");
-			}
-		}
-		msg.direction = (msg.to == null || msg.to.asBare().equals(localJidBare)) ? MessageReceived : MessageSent;
-		if (msg.from != null && msg.from.asBare().equals(localJidBare)) msg.direction = MessageSent;
-		msg.status = msg.direction == MessageReceived ? MessageDeliveredToDevice : MessageDeliveredToServer; // Delivered to us, a device
-
-		final recipients: Map<String, Bool> = [];
-		final replyTo: Map<String, Bool> = [];
-		if (msg.to != null) {
-			recipients[msg.to.asBare().asString()] = true;
-		}
-		if (msg.direction == MessageReceived && msg.from != null) {
-			replyTo[stanza.attr.get("type") == "groupchat" ? msg.from.asBare().asString() : msg.from.asString()] = true;
-		} else if(msg.to != null) {
-			replyTo[msg.to.asString()] = true;
-		}
-
-		final addresses = stanza.getChild("addresses", "http://jabber.org/protocol/address");
-		var anyExtendedReplyTo = false;
-		if (addresses != null) {
-			for (address in addresses.allTags("address")) {
-				final jid = address.attr.get("jid");
-				if (address.attr.get("type") == "noreply") {
-					replyTo.clear();
-				} else if (jid == null) {
-					trace("No support for addressing to non-jid", address);
-					return null;
-				} else if (address.attr.get("type") == "to" || address.attr.get("type") == "cc") {
-					recipients[JID.parse(jid).asBare().asString()] = true;
-					if (!anyExtendedReplyTo) replyTo[JID.parse(jid).asString()] = true; // reply all
-				} else if (address.attr.get("type") == "replyto" || address.attr.get("type") == "replyroom") {
-					if (!anyExtendedReplyTo) {
-						replyTo.clear();
-						anyExtendedReplyTo = true;
-					}
-					replyTo[JID.parse(jid).asString()] = true;
-				} else if (address.attr.get("type") == "ofrom") {
-					if (JID.parse(jid).domain == msg.sender?.domain) {
-						// TODO: check that domain supports extended addressing
-						msg.sender = JID.parse(jid).asBare();
-					}
-				}
-			}
-		}
-
-		msg.recipients = ({ iterator: () -> recipients.keys() }).map((s) -> JID.parse(s));
-		msg.recipients.sort((x, y) -> Reflect.compare(x.asString(), y.asString()));
-		msg.replyTo = ({ iterator: () -> replyTo.keys() }).map((s) -> JID.parse(s));
-		msg.replyTo.sort((x, y) -> Reflect.compare(x.asString(), y.asString()));
-
-		final msgFrom = msg.from;
-		if (msg.direction == MessageReceived && msgFrom != null && msg.replyTo.find((r) -> r.asBare().equals(msgFrom.asBare())) == null) {
-			trace("Don't know what chat message without from in replyTo belongs in", stanza);
-			return null;
-		}
-
-		for (ref in stanza.allTags("reference", "urn:xmpp:reference:0")) {
-			if (ref.attr.get("begin") == null && ref.attr.get("end") == null) {
-				final sims = ref.getChild("media-sharing", "urn:xmpp:sims:1");
-				if (sims != null) msg.attachSims(sims);
-			}
-		}
-
-		for (sims in stanza.allTags("media-sharing", "urn:xmpp:sims:1")) {
-			msg.attachSims(sims);
-		}
-
-		if (msg.text == null && msg.attachments.length < 1) return null;
-
-		for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) {
-			msg.payloads.push(fallback);
-		}
-
-		final unstyled = stanza.getChild("unstyled", "urn:xmpp:styling:0");
-		if (unstyled != null) {
-			msg.payloads.push(unstyled);
+		switch Message.fromStanza(stanza, localJid) {
+			case ChatMessageStanza(message):
+				return message;
+			default:
+				return null;
 		}
-
-		return msg;
 	}
 
 	public function attachSims(sims: Stanza) {
@@ -210,6 +86,14 @@ class ChatMessage {
 		if (uris.length > 0) attachments.push(new ChatAttachment(name, mime, size == null ? null : Std.parseInt(size), uris, hashes));
 	}
 
+	public function reply() {
+		final m = new ChatMessage();
+		m.groupchat = groupchat;
+		m.threadId = threadId ?? ID.long();
+		m.replyToMessage = this;
+		return m;
+	}
+
 	public function set_localId(localId:String):String {
 		if(this.localId != null) {
 			throw new Exception("Message already has a localId set");
@@ -229,19 +113,16 @@ class ChatMessage {
 	}
 
 	public function html():String {
-		var body = text ?? "";
+		final codepoints = StringUtil.codepointArray(text ?? "");
 		// TODO: not every app will implement every feature. How should the app tell us what fallbacks to handle?
-		final fallback = payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:fallback:0" && (p.attr.get("for") == "jabber:x:oob" || p.attr.get("for") == "urn:xmpp:sims:1"));
-		if (fallback != null) {
-			final bodyFallback = fallback.getChild("body");
-			if (bodyFallback != null) {
-				final codepoints = StringUtil.codepointArray(body);
-				final start = Std.parseInt(bodyFallback.attr.get("start") ?? "0") ?? 0;
-				final end = Std.parseInt(bodyFallback.attr.get("end") ?? Std.string(codepoints.length)) ?? codepoints.length;
-				codepoints.splice(start, (end - start));
-				body = codepoints.join("");
-			}
+		final fallbacks: Array<{start: Int, end: Int}> = cast payloads.filter(
+			(p) -> p.attr.get("xmlns") == "urn:xmpp:fallback:0" && (p.attr.get("for") == "jabber:x:oob" || p.attr.get("for") == "urn:xmpp:sims:1" || (replyToMessage != null && p.attr.get("for") == "urn:xmpp:reply:0"))
+		).map((p) -> p.getChild("body")).map((b) -> b == null ? null : { start: Std.parseInt(b.attr.get("start") ?? "0") ?? 0, end: Std.parseInt(b.attr.get("end") ?? Std.string(codepoints.length)) ?? codepoints.length }).filter((b) -> b != null);
+		fallbacks.sort((x, y) -> x.start - y.start);
+		for (fallback in fallbacks) {
+			codepoints.splice(fallback.start, (fallback.end - fallback.start));
 		}
+		final body = codepoints.join("");
 		return payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled") == null ? XEP0393.parse(body).map((s) -> s.toString()).join("") : StringTools.htmlEscape(body);
 	}
 
@@ -269,9 +150,9 @@ class ChatMessage {
 		return threadId == null ? null : Identicon.svg(threadId);
 	}
 
-	public function asStanza(?type: String):Stanza {
+	public function asStanza():Stanza {
 		var body = text;
-		var attrs: haxe.DynamicAccess<String> = { type: type ?? "chat" };
+		var attrs: haxe.DynamicAccess<String> = { type: groupchat ? "groupchat" : "chat" };
 		if (from != null) attrs.set("from", from.asString());
 		if (to != null) attrs.set("to", to.asString());
 		if (localId != null) attrs.set("id", localId);
@@ -284,7 +165,51 @@ class ChatMessage {
 				addresses.tag("address", { type: "to", jid: recipient.asString(), delivered: "true" }).up();
 			}
 			addresses.up();
+		} else if (recipients.length == 1 && to == null) {
+			attrs.set("to", recipients[0].asString());
 		}
+
+		final replyToM = replyToMessage;
+		if (replyToM != null) {
+			if (body == null) body = "";
+			final lines = replyToM.text?.split("\n") ?? [];
+			var quoteText = "";
+			for (line in lines) {
+				if (!~/^(?:> ?){3,}/.match(line)) {
+					if (line.charAt(0) == ">") {
+						quoteText += ">" + line;
+					} else {
+						quoteText += "> " + line;
+					}
+				}
+			}
+			final reaction = EmojiUtil.isEmoji(StringTools.trim(body)) ? StringTools.trim(body) : null;
+			body = quoteText + "\n" + body;
+			final replyId = replyToM.groupchat ? replyToM.serverId : replyToM.localId;
+			if (replyId != null) {
+				final codepoints = StringUtil.codepointArray(quoteText);
+				if (reaction != null) {
+					final addedReactions: Map<String, Bool> = [];
+					stanza.tag("reactions", { xmlns: "urn:xmpp:reactions:0", id: replyId });
+					stanza.textTag("reaction", reaction);
+					addedReactions[reaction] = true;
+
+					for (areaction => senders in replyToM.reactions) {
+						if (!(addedReactions[areaction] ?? false) && senders.contains(senderId())) {
+							addedReactions[areaction] = true;
+							stanza.textTag("reaction", areaction);
+						}
+					}
+					stanza.up();
+					stanza.tag("fallback", { xmlns: "urn:xmpp:fallback:0", "for": "urn:xmpp:reactions:0" })
+						.tag("body").up().up();
+				}
+				stanza.tag("fallback", { xmlns: "urn:xmpp:fallback:0", "for": "urn:xmpp:reply:0" })
+						.tag("body", { start: "0", end: Std.string(codepoints.length + 1) }).up().up();
+				stanza.tag("reply", { xmlns: "urn:xmpp:reply:0", to: replyToM.from?.asString(), id: replyId }).up();
+			}
+		}
+
 		for (attachment in attachments) {
 			stanza
 				.tag("reference", { xmlns: "urn:xmpp:reference:0", type: "data" })
diff --git a/xmpp/Client.hx b/xmpp/Client.hx
index 1c0cf16..f30db57 100644
--- a/xmpp/Client.hx
+++ b/xmpp/Client.hx
@@ -139,28 +139,30 @@ class Client extends xmpp.EventEmitter {
 				}
 			}
 
-			var chatMessage = ChatMessage.fromStanza(stanza, this.jid);
-			if (chatMessage != null) {
-				var chat = getChat(chatMessage.chatId());
-				if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId());
-				if (chat != null) {
-					final updateChat = (chatMessage) -> {
-						if (chatMessage.versions.length < 1 || chat.lastMessageId() == chatMessage.serverId || chat.lastMessageId() == chatMessage.localId) {
-							chat.setLastMessage(chatMessage);
-							if (chatMessage.versions.length < 1) chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0);
-							chatActivity(chat);
+			switch (Message.fromStanza(stanza, this.jid)) {
+				case ChatMessageStanza(chatMessage):
+					var chat = getChat(chatMessage.chatId());
+					if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId());
+					if (chat != null) {
+						final updateChat = (chatMessage) -> {
+							if (chatMessage.versions.length < 1 || chat.lastMessageId() == chatMessage.serverId || chat.lastMessageId() == chatMessage.localId) {
+								chat.setLastMessage(chatMessage);
+								if (chatMessage.versions.length < 1) chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0);
+								chatActivity(chat);
+							}
+							notifyMessageHandlers(chatMessage);
+						};
+						chatMessage = chat.prepareIncomingMessage(chatMessage, stanza);
+						if (chatMessage.serverId == null) {
+							updateChat(chatMessage);
+						} else {
+							persistence.storeMessage(accountId(), chatMessage, updateChat);
 						}
-						notifyMessageHandlers(chatMessage);
-					};
-					chatMessage = chat.prepareIncomingMessage(chatMessage, stanza);
-					final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0");
-					if (replace == null || replace.attr.get("id") == null) {
-						if (chatMessage.serverId != null) persistence.storeMessage(accountId(), chatMessage);
-						updateChat(chatMessage);
-					} else {
-						persistence.correctMessage(accountId(), replace.attr.get("id"), chatMessage, updateChat);
 					}
-				}
+				case ReactionUpdateStanza(update):
+					persistence.storeReaction(accountId(), update, (stored) -> if (stored != null) notifyMessageHandlers(stored));
+				default:
+					// ignore
 			}
 
 			final pubsubEvent = PubsubEvent.fromStanza(stanza);
@@ -820,8 +822,15 @@ class Client extends xmpp.EventEmitter {
 		);
 		sync.setNewestPageFirst(false);
 		sync.onMessages((messageList) -> {
-			for (message in messageList.messages) {
-				persistence.storeMessage(accountId(), message);
+			for (m in messageList.messages) {
+				switch (m) {
+					case ChatMessageStanza(message):
+						persistence.storeMessage(accountId(), message, (m)->{});
+					case ReactionUpdateStanza(update):
+					persistence.storeReaction(accountId(), update, (m)->{});
+					default:
+						// ignore
+				}
 			}
 			if (sync.hasMore()) {
 				sync.fetchNext();
diff --git a/xmpp/EmojiUtil.hx b/xmpp/EmojiUtil.hx
new file mode 100644
index 0000000..9a61e53
--- /dev/null
+++ b/xmpp/EmojiUtil.hx
@@ -0,0 +1,258 @@
+/*
+ * Copyright (c) 2017, Daniel Gultsch All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation and/or
+ * other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package xmpp;
+
+class EmojiUtil {
+
+	public static final MISC_SYMBOLS_AND_PICTOGRAPHS = new UnicodeRange(0x1F300, 0x1F5FF);
+	public static final SUPPLEMENTAL_SYMBOLS = new UnicodeRange(0x1F900, 0x1F9FF);
+	public static final EMOTICONS = new UnicodeRange(0x1F600, 0x1FAF6);
+	//public static final UnicodeRange TRANSPORT_SYMBOLS = new UnicodeRange(0x1F680, 0x1F6FF);
+	public static final MISC_SYMBOLS = new UnicodeRange(0x2600, 0x26FF);
+	public static final DINGBATS = new UnicodeRange(0x2700, 0x27BF);
+	public static final ENCLOSED_ALPHANUMERIC_SUPPLEMENT = new UnicodeRange(0x1F100, 0x1F1FF);
+	public static final ENCLOSED_IDEOGRAPHIC_SUPPLEMENT = new UnicodeRange(0x1F200, 0x1F2FF);
+	public static final REGIONAL_INDICATORS = new UnicodeRange(0x1F1E6, 0x1F1FF);
+	public static final GEOMETRIC_SHAPES = new UnicodeRange(0x25A0, 0x25FF);
+	public static final LATIN_SUPPLEMENT = new UnicodeRange(0x80, 0xFF);
+	public static final MISC_TECHNICAL = new UnicodeRange(0x2300, 0x23FF);
+	public static final TAGS = new UnicodeRange(0xE0020, 0xE007F);
+	public static final CYK_SYMBOLS_AND_PUNCTUATION = new UnicodeList(0x3030, 0x303D);
+	public static final LETTERLIKE_SYMBOLS = new UnicodeList(0x2122, 0x2139);
+
+	public static final KEYCAP_COMBINEABLE = new UnicodeBlocks(new UnicodeList(0x23), new UnicodeList(0x2A), new UnicodeRange(0x30, 0x39));
+
+	public static final SYMBOLIZE = new UnicodeBlocks(
+			GEOMETRIC_SHAPES,
+			LATIN_SUPPLEMENT,
+			CYK_SYMBOLS_AND_PUNCTUATION,
+			LETTERLIKE_SYMBOLS,
+			KEYCAP_COMBINEABLE);
+	public static final EMOJIS = new UnicodeBlocks(
+			MISC_SYMBOLS_AND_PICTOGRAPHS,
+			SUPPLEMENTAL_SYMBOLS,
+			EMOTICONS,
+			//TRANSPORT_SYMBOLS,
+			MISC_SYMBOLS,
+			DINGBATS,
+			ENCLOSED_ALPHANUMERIC_SUPPLEMENT,
+			ENCLOSED_IDEOGRAPHIC_SUPPLEMENT,
+			MISC_TECHNICAL);
+
+	public static final MAX_EMOIJS = 42;
+
+	public static final ZWJ = 0x200D;
+	public static final VARIATION_16 = 0xFE0F;
+	public static final COMBINING_ENCLOSING_KEYCAP = 0x20E3;
+	public static final BLACK_FLAG = 0x1F3F4;
+	public static final FITZPATRICK = new UnicodeRange(0x1F3FB, 0x1F3FF);
+
+	private static function parse(str: String) {
+		final symbols = [];
+		var builder = new Builder();
+		var needsFinalBuild = false;
+		final input = StringUtil.rawCodepointArray(str);
+		for (i in 0...input.length) {
+			final cp = input[i];
+			if (builder.offer(cp)) {
+				needsFinalBuild = true;
+			} else {
+				symbols.push(builder.build());
+				builder = new Builder();
+				if (builder.offer(cp)) {
+					needsFinalBuild = true;
+				}
+			}
+		}
+		if (needsFinalBuild) {
+			symbols.push(builder.build());
+		}
+		return symbols;
+	}
+
+	public static function isEmoji(input: String) {
+		final symbols = parse(input);
+		return symbols.length == 1 && symbols[0].isEmoji();
+	}
+
+	public static function isOnlyEmoji(input: String) {
+		final symbols = parse(input);
+		for (symbol in symbols) {
+			if (!symbol.isEmoji()) {
+				return false;
+			}
+		}
+		return symbols.length > 0;
+	}
+}
+
+abstract class Symbol {
+	private final value: String;
+
+	public function new(codepoints: Array<Int>) {
+		final builder = new StringBuf();
+		for (codepoint in codepoints) {
+			builder.addChar(codepoint);
+		}
+		this.value = builder.toString();
+	}
+
+	public abstract function isEmoji():Bool;
+
+	public function toString() {
+		return value;
+	}
+}
+
+class Emoji extends Symbol {
+	public function new(codepoints: Array<Int>) {
+		super(codepoints);
+	}
+
+	public function isEmoji() {
+		return true;
+	}
+}
+
+class Other extends Symbol {
+	public function new(codepoints: Array<Int>) {
+		super(codepoints);
+	}
+
+	public function isEmoji() {
+		return false;
+	}
+}
+
+class Builder {
+	private final codepoints = [];
+
+	public function new() {}
+
+	public function offer(codepoint: Int) {
+		var add = false;
+		if (this.codepoints.length == 0) {
+			if (EmojiUtil.SYMBOLIZE.contains(codepoint)) {
+				add = true;
+			} else if (EmojiUtil.REGIONAL_INDICATORS.contains(codepoint)) {
+				add = true;
+			} else if (EmojiUtil.EMOJIS.contains(codepoint) && !EmojiUtil.FITZPATRICK.contains(codepoint) && codepoint != EmojiUtil.ZWJ) {
+				add = true;
+			}
+		} else {
+			var previous = codepoints[codepoints.length - 1];
+			if (codepoints[0] == EmojiUtil.BLACK_FLAG) {
+				add = EmojiUtil.TAGS.contains(codepoint);
+			} else if (EmojiUtil.COMBINING_ENCLOSING_KEYCAP == codepoint) {
+				add = EmojiUtil.KEYCAP_COMBINEABLE.contains(previous) || previous == EmojiUtil.VARIATION_16;
+			} else if (EmojiUtil.SYMBOLIZE.contains(previous)) {
+				add = codepoint == EmojiUtil.VARIATION_16;
+			} else if (EmojiUtil.REGIONAL_INDICATORS.contains(previous) && EmojiUtil.REGIONAL_INDICATORS.contains(codepoint)) {
+				add = codepoints.length == 1;
+			} else if (previous == EmojiUtil.VARIATION_16) {
+				add = isMerger(codepoint) || codepoint == EmojiUtil.VARIATION_16;
+			} else if (EmojiUtil.FITZPATRICK.contains(previous)) {
+				add = codepoint == EmojiUtil.ZWJ;
+			} else if (EmojiUtil.ZWJ == previous) {
+				add = EmojiUtil.EMOJIS.contains(codepoint);
+			} else if (isMerger(codepoint)) {
+				add = true;
+			} else if (codepoint == EmojiUtil.VARIATION_16 && EmojiUtil.EMOJIS.contains(previous)) {
+				add = true;
+			}
+		}
+		if (add) {
+			codepoints.push(codepoint);
+			return true;
+		} else {
+			return false;
+		}
+	}
+
+	private static function isMerger(codepoint: Int) {
+		return codepoint == EmojiUtil.ZWJ || EmojiUtil.FITZPATRICK.contains(codepoint);
+	}
+
+	public function build(): Symbol {
+		if (codepoints.length > 0 && EmojiUtil.SYMBOLIZE.contains(codepoints[codepoints.length - 1])) {
+			return new Other(codepoints);
+		} else if (codepoints.length > 1 && EmojiUtil.KEYCAP_COMBINEABLE.contains(codepoints[0]) && codepoints[codepoints.length - 1] != EmojiUtil.COMBINING_ENCLOSING_KEYCAP) {
+			return new Other(codepoints);
+		}
+		return codepoints.length == 0 ? new Other(codepoints) : new Emoji(codepoints);
+	}
+}
+
+class UnicodeBlocks implements UnicodeSet {
+	final unicodeSets: Array<UnicodeSet>;
+
+	public function new(...sets: UnicodeSet) {
+		this.unicodeSets = sets;
+	}
+
+	public function contains(codepoint: Int) {
+		for (unicodeSet in unicodeSets) {
+			if (unicodeSet.contains(codepoint)) {
+				return true;
+			}
+		}
+		return false;
+	}
+}
+
+interface UnicodeSet {
+	public function contains(codepoint: Int):Bool;
+}
+
+class UnicodeList implements UnicodeSet {
+	final list: Array<Int>;
+
+	public function new(...codes: Int) {
+		this.list = codes;
+	}
+
+	public function contains(codepoint: Int) {
+		return this.list.contains(codepoint);
+	}
+}
+
+class UnicodeRange implements UnicodeSet {
+	private final lower: Int;
+	private final upper: Int;
+
+    public function new(lower: Int, upper: Int) {
+		this.lower = lower;
+		this.upper = upper;
+	}
+
+	public function contains(codePoint: Int) {
+		return codePoint >= lower && codePoint <= upper;
+	}
+}
diff --git a/xmpp/Message.hx b/xmpp/Message.hx
new file mode 100644
index 0000000..b0ce876
--- /dev/null
+++ b/xmpp/Message.hx
@@ -0,0 +1,196 @@
+package xmpp;
+
+using Lambda;
+
+enum MessageDirection {
+	MessageReceived;
+	MessageSent;
+}
+
+enum MessageStatus {
+	MessagePending; // Message is waiting in client for sending
+	MessageDeliveredToServer; // Server acknowledged receipt of the message
+	MessageDeliveredToDevice; //The message has been delivered to at least one client device
+	MessageFailedToSend; // There was an error sending this message
+}
+
+enum MessageStanza {
+	ErrorMessageStanza(stanza: Stanza);
+	ChatMessageStanza(message: ChatMessage);
+	ReactionUpdateStanza(update: ReactionUpdate);
+	UnknownMessageStanza(stanza: Stanza);
+}
+
+@:nullSafety(Strict)
+class Message {
+	public static function fromStanza(stanza:Stanza, localJid:JID, ?inputTimestamp: String):MessageStanza {
+		if (stanza.attr.get("type") == "error") return ErrorMessageStanza(stanza);
+
+		var msg = new ChatMessage();
+		final timestamp = stanza.findText("{urn:xmpp:delay}delay@stamp") ?? inputTimestamp ?? Date.format(std.Date.now());
+		msg.timestamp = timestamp;
+		msg.threadId = stanza.getChildText("thread");
+		msg.lang = stanza.attr.get("xml:lang");
+		msg.text = stanza.getChildText("body");
+		if (msg.text != null && (msg.lang == null || msg.lang == "")) {
+			msg.lang = stanza.getChild("body")?.attr.get("xml:lang");
+		}
+		final from = stanza.attr.get("from");
+		msg.from = from == null ? null : JID.parse(from);
+		msg.groupchat = stanza.attr.get("type") == "groupchat";
+		msg.sender = stanza.attr.get("type") == "groupchat" ? msg.from : msg.from?.asBare();
+		final localJidBare = localJid.asBare();
+		final domain = localJid.domain;
+		final to = stanza.attr.get("to");
+		msg.to = to == null ? localJid : JID.parse(to);
+
+		if (msg.from != null && msg.from.equals(localJidBare)) {
+			var carbon = stanza.getChild("received", "urn:xmpp:carbons:2");
+			if (carbon == null) carbon = stanza.getChild("sent", "urn:xmpp:carbons:2");
+			if (carbon != null) {
+				var fwd = carbon.getChild("forwarded", "urn:xmpp:forward:0");
+				if(fwd != null) return fromStanza(fwd.getFirstChild(), localJid);
+			}
+		}
+
+		final localId = stanza.attr.get("id");
+		if (localId != null) msg.localId = localId;
+		var altServerId = null;
+		for (stanzaId in stanza.allTags("stanza-id", "urn:xmpp:sid:0")) {
+			final id = stanzaId.attr.get("id");
+			if ((stanzaId.attr.get("by") == domain || stanzaId.attr.get("by") == localJidBare.asString()) && id != null) {
+				msg.serverIdBy = localJidBare.asString();
+				msg.serverId = id;
+				break;
+			}
+			altServerId = stanzaId;
+		}
+		if (msg.serverId == null && altServerId != null && stanza.attr.get("type") != "error") {
+			final id = altServerId.attr.get("id");
+			if (id != null) {
+				msg.serverId = id;
+				msg.serverIdBy = altServerId.attr.get("by");
+			}
+		}
+		msg.direction = (msg.to == null || msg.to.asBare().equals(localJidBare)) ? MessageReceived : MessageSent;
+		if (msg.from != null && msg.from.asBare().equals(localJidBare)) msg.direction = MessageSent;
+		msg.status = msg.direction == MessageReceived ? MessageDeliveredToDevice : MessageDeliveredToServer; // Delivered to us, a device
+
+		final recipients: Map<String, Bool> = [];
+		final replyTo: Map<String, Bool> = [];
+		if (msg.to != null) {
+			recipients[msg.to.asBare().asString()] = true;
+		}
+		if (msg.direction == MessageReceived && msg.from != null) {
+			replyTo[stanza.attr.get("type") == "groupchat" ? msg.from.asBare().asString() : msg.from.asString()] = true;
+		} else if(msg.to != null) {
+			replyTo[msg.to.asString()] = true;
+		}
+
+		final addresses = stanza.getChild("addresses", "http://jabber.org/protocol/address");
+		var anyExtendedReplyTo = false;
+		if (addresses != null) {
+			for (address in addresses.allTags("address")) {
+				final jid = address.attr.get("jid");
+				if (address.attr.get("type") == "noreply") {
+					replyTo.clear();
+				} else if (jid == null) {
+					trace("No support for addressing to non-jid", address);
+					return UnknownMessageStanza(stanza);
+				} else if (address.attr.get("type") == "to" || address.attr.get("type") == "cc") {
+					recipients[JID.parse(jid).asBare().asString()] = true;
+					if (!anyExtendedReplyTo) replyTo[JID.parse(jid).asString()] = true; // reply all
+				} else if (address.attr.get("type") == "replyto" || address.attr.get("type") == "replyroom") {
+					if (!anyExtendedReplyTo) {
+						replyTo.clear();
+						anyExtendedReplyTo = true;
+					}
+					replyTo[JID.parse(jid).asString()] = true;
+				} else if (address.attr.get("type") == "ofrom") {
+					if (JID.parse(jid).domain == msg.sender?.domain) {
+						// TODO: check that domain supports extended addressing
+						msg.sender = JID.parse(jid).asBare();
+					}
+				}
+			}
+		}
+
+		msg.recipients = ({ iterator: () -> recipients.keys() }).map((s) -> JID.parse(s));
+		msg.recipients.sort((x, y) -> Reflect.compare(x.asString(), y.asString()));
+		msg.replyTo = ({ iterator: () -> replyTo.keys() }).map((s) -> JID.parse(s));
+		msg.replyTo.sort((x, y) -> Reflect.compare(x.asString(), y.asString()));
+
+		final msgFrom = msg.from;
+		if (msg.direction == MessageReceived && msgFrom != null && msg.replyTo.find((r) -> r.asBare().equals(msgFrom.asBare())) == null) {
+			trace("Don't know what chat message without from in replyTo belongs in", stanza);
+			return UnknownMessageStanza(stanza);
+		}
+
+		final reactionsEl = stanza.getChild("reactions", "urn:xmpp:reactions:0");
+		if (reactionsEl != null) {
+			// A reaction update is never also a chat message
+			final reactions = reactionsEl.allTags("reaction").map((r) -> r.getText());
+			final reactionId = reactionsEl.attr.get("id");
+			if (reactionId != null) {
+				return ReactionUpdateStanza(new ReactionUpdate(
+					stanza.attr.get("id") ?? ID.long(),
+					stanza.attr.get("type") == "groupchat" ? reactionId : null,
+					stanza.attr.get("type") != "groupchat" ? reactionId : null,
+					msg.chatId(),
+					timestamp,
+					msg.senderId(),
+					reactions
+				));
+			}
+		}
+
+		for (ref in stanza.allTags("reference", "urn:xmpp:reference:0")) {
+			if (ref.attr.get("begin") == null && ref.attr.get("end") == null) {
+				final sims = ref.getChild("media-sharing", "urn:xmpp:sims:1");
+				if (sims != null) msg.attachSims(sims);
+			}
+		}
+
+		for (sims in stanza.allTags("media-sharing", "urn:xmpp:sims:1")) {
+			msg.attachSims(sims);
+		}
+
+		if (msg.text == null && msg.attachments.length < 1) return UnknownMessageStanza(stanza);
+
+		for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) {
+			msg.payloads.push(fallback);
+		}
+
+		final unstyled = stanza.getChild("unstyled", "urn:xmpp:styling:0");
+		if (unstyled != null) {
+			msg.payloads.push(unstyled);
+		}
+
+		final reply = stanza.getChild("reply", "urn:xmpp:reply:0");
+		if (reply != null) {
+			final replyToJid = reply.attr.get("to");
+			final replyToID = reply.attr.get("id");
+			if (replyToID != null) {
+				// Reply stub
+				final replyToMessage = new ChatMessage();
+				replyToMessage.groupchat = msg.groupchat;
+				replyToMessage.from = replyToJid == null ? null : JID.parse(replyToJid);
+				if (msg.groupchat) {
+					replyToMessage.serverId = replyToID;
+				} else {
+					replyToMessage.localId = replyToID;
+				}
+				msg.replyToMessage = replyToMessage;
+			}
+		}
+
+		final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0");
+		final replaceId  = replace?.attr?.get("id");
+		if (replaceId != null) {
+			msg.versions = [msg.clone()];
+			Reflect.setField(msg, "localId", replaceId);
+		}
+
+		return ChatMessageStanza(msg);
+	}
+}
diff --git a/xmpp/MessageSync.hx b/xmpp/MessageSync.hx
index ec39db3..a32e14e 100644
--- a/xmpp/MessageSync.hx
+++ b/xmpp/MessageSync.hx
@@ -3,14 +3,14 @@ package xmpp;
 import haxe.Exception;
 
 import xmpp.Client;
-import xmpp.ChatMessage;
+import xmpp.Message;
 import xmpp.GenericStream;
 import xmpp.ResultSet;
 import xmpp.queries.MAMQuery;
 
 typedef MessageList = {
 	var sync : MessageSync;
-	var messages : Array<ChatMessage>;
+	var messages : Array<MessageStanza>;
 }
 
 typedef MessageListHandler = (MessageList)->Void;
@@ -44,7 +44,7 @@ class MessageSync {
 		if (complete) {
 			throw new Exception("Attempt to fetch messages, but already complete");
 		}
-		final messages:Array<ChatMessage> = [];
+		final messages:Array<MessageStanza> = [];
 		if (lastPage == null) {
 			if (newestPageFirst == true && (filter.page == null || filter.page.before == null)) {
 				if (filter.page == null) filter.page = {};
@@ -80,13 +80,15 @@ class MessageSync {
 				jmi.set(jmiChildren[0].attr.get("id"), originalMessage);
 			}
 
-			var msg = ChatMessage.fromStanza(originalMessage, client.jid);
-			if (msg == null) return EventHandled;
+			var msg = Message.fromStanza(originalMessage, client.jid, timestamp);
 
-			msg.serverId = result.attr.get("id");
-			msg.serverIdBy = serviceJID;
-			msg.syncPoint = true;
-			msg.timestamp = timestamp;
+			switch (msg) {
+				case ChatMessageStanza(chatMessage):
+					chatMessage.serverId = result.attr.get("id");
+					chatMessage.serverIdBy = serviceJID;
+					chatMessage.syncPoint = true;
+				default:
+			}
 
 			messages.push(msg);
 
diff --git a/xmpp/Persistence.hx b/xmpp/Persistence.hx
index 4dbc56f..116c8ba 100644
--- a/xmpp/Persistence.hx
+++ b/xmpp/Persistence.hx
@@ -1,17 +1,18 @@
 package xmpp;
 
 import haxe.io.BytesData;
-import xmpp.ChatMessage;
 import xmpp.Chat;
+import xmpp.ChatMessage;
+import xmpp.Message;
 
 abstract class Persistence {
 	abstract public function lastId(accountId: String, chatId: Null<String>, callback:(serverId:Null<String>)->Void):Void;
 	abstract public function storeChat(accountId: String, chat: Chat):Void;
 	abstract public function getChats(accountId: String, callback: (chats:Array<SerializedChat>)->Void):Void;
 	abstract public function getChatsUnreadDetails(accountId: String, chats: Array<Chat>, callback: (details:Array<{ chatId: String, message: ChatMessage, unreadCount: Int }>)->Void):Void;
-	abstract public function storeMessage(accountId: String, message: ChatMessage):Void;
+	abstract public function storeReaction(accountId: String, update: ReactionUpdate, callback: (Null<ChatMessage>)->Void):Void;
+	abstract public function storeMessage(accountId: String, message: ChatMessage, callback: (ChatMessage)->Void):Void;
 	abstract public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus, callback: (ChatMessage)->Void):Void;
-	abstract public function correctMessage(accountId: String, localId: String, message: ChatMessage, callback: (ChatMessage)->Void):Void;
 	abstract public function getMessages(accountId: String, chatId: String, beforeId: Null<String>, beforeTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void;
 	abstract public function getMediaUri(hashAlgorithm:String, hash:BytesData, callback: (uri:Null<String>)->Void):Void;
 	abstract public function storeMedia(mime:String, bytes:BytesData, callback: ()->Void):Void;
diff --git a/xmpp/ReactionUpdate.hx b/xmpp/ReactionUpdate.hx
new file mode 100644
index 0000000..908ee49
--- /dev/null
+++ b/xmpp/ReactionUpdate.hx
@@ -0,0 +1,37 @@
+package xmpp;
+
+@:nullSafety(Strict)
+class ReactionUpdate {
+	public final updateId: String;
+	public final serverId: Null<String>;
+	public final localId: Null<String>;
+	public final chatId: String;
+	public final timestamp: String;
+	public final senderId: String;
+	public final reactions: Array<String>;
+
+	public function new(updateId: String, serverId: Null<String>, localId: Null<String>, chatId: String, timestamp: String, senderId: String, reactions: Array<String>) {
+		if (serverId == null && localId == null) throw "ReactionUpdate serverId and localId cannot both be null";
+		this.updateId = updateId;
+		this.serverId = serverId;
+		this.localId = localId;
+		this.chatId = chatId;
+		this.timestamp = timestamp;
+		this.senderId = senderId;
+		this.reactions = reactions;
+	}
+
+	// Note that using this version means you don't get any fallbacks!
+	public function asStanza():Stanza {
+		var attrs: haxe.DynamicAccess<String> = { type: serverId == null ? "chat" : "groupchat", id: updateId };
+		var stanza = new Stanza("message", attrs);
+
+		stanza.tag("reactions", { xmlns: "urn:xmpp:reactions:0", id: localId ?? serverId });
+		for (reaction in reactions) {
+			stanza.textTag("reaction", reaction);
+		}
+		stanza.up();
+
+		return stanza;
+	}
+}
diff --git a/xmpp/persistence/browser.js b/xmpp/persistence/browser.js
index fbf030c..f635992 100644
--- a/xmpp/persistence/browser.js
+++ b/xmpp/persistence/browser.js
@@ -10,18 +10,11 @@ exports.xmpp.persistence = {
 			dbOpenReq.onupgradeneeded = (event) => {
 				const upgradeDb = event.target.result;
 				if (!db.objectStoreNames.contains("messages")) {
-					const messages = upgradeDb.createObjectStore("messages", { keyPath: ["account", "serverIdBy", "serverId", "localId"] });
+					const messages = upgradeDb.createObjectStore("messages", { keyPath: ["account", "serverId", "serverIdBy", "localId"] });
 					messages.createIndex("chats", ["account", "chatId", "timestamp"]);
 					messages.createIndex("localId", ["account", "localId", "chatId"]);
-				}
-				const messages = event.target.transaction.objectStore("messages");
-				if (!messages.indexNames.contains("accounts")) {
 					messages.createIndex("accounts", ["account", "timestamp"]);
 				}
-				if (messages.index("localId").keyPath.toString() !== "account,localId,chatId") {
-					messages.deleteIndex("localId");
-					messages.createIndex("localId", ["account", "localId", "chatId"]);
-				}
 				if (!db.objectStoreNames.contains("keyvaluepairs")) {
 					upgradeDb.createObjectStore("keyvaluepairs");
 				}
@@ -31,21 +24,14 @@ exports.xmpp.persistence = {
 				if (!db.objectStoreNames.contains("services")) {
 					upgradeDb.createObjectStore("services", { keyPath: ["account", "serviceId"] });
 				}
+				if (!db.objectStoreNames.contains("reactions")) {
+					const reactions = upgradeDb.createObjectStore("reactions", { keyPath: ["account", "chatId", "senderId", "updateId"] });
+					reactions.createIndex("senders", ["account", "chatId", "messageId", "senderId", "timestamp"]);
+				}
 			};
 			dbOpenReq.onsuccess = (event) => {
 				db = event.target.result;
-				if (!db.objectStoreNames.contains("messages") || !db.objectStoreNames.contains("keyvaluepairs") || !db.objectStoreNames.contains("chats") || !db.objectStoreNames.contains("services")) {
-					db.close();
-					openDb(db.version + 1);
-					return;
-				}
-				const tx = db.transaction(["messages"], "readonly");
-				if (!tx.objectStore("messages").indexNames.contains("accounts")) {
-					db.close();
-					openDb(db.version + 1);
-					return;
-				}
-				if (tx.objectStore("messages").index("localId").keyPath.toString() !== "account,localId,chatId") {
+				if (!db.objectStoreNames.contains("messages") || !db.objectStoreNames.contains("keyvaluepairs") || !db.objectStoreNames.contains("chats") || !db.objectStoreNames.contains("services") || !db.objectStoreNames.contains("reactions")) {
 					db.close();
 					openDb(db.version + 1);
 					return;
@@ -69,7 +55,13 @@ exports.xmpp.persistence = {
 			});
 		}
 
-		function hydrateMessage(value) {
+		async function hydrateMessage(value) {
+			if (!value) return null;
+
+			const tx = db.transaction(["messages"], "readonly");
+			const store = tx.objectStore("messages");
+			let replyToMessage = value.replyToMessage && await hydrateMessage((await promisifyRequest(store.openCursor(IDBKeyRange.only(value.replyToMessage))))?.value);
+
 			const message = new xmpp.ChatMessage();
 			message.localId = value.localId ? value.localId : null;
 			message.serverId = value.serverId ? value.serverId : null;
@@ -81,10 +73,13 @@ exports.xmpp.persistence = {
 			message.sender = value.sender && xmpp.JID.parse(value.sender);
 			message.recipients = value.recipients.map((r) => xmpp.JID.parse(r));
 			message.replyTo = value.replyTo.map((r) => xmpp.JID.parse(r));
+			message.replyToMessage = replyToMessage;
 			message.threadId = value.threadId;
 			message.attachments = value.attachments;
+			message.reactions = value.reactions;
 			message.text = value.text;
 			message.lang = value.lang;
+			message.groupchat = value.groupchat;
 			message.direction = value.direction == "MessageReceived" ? xmpp.MessageDirection.MessageReceived : xmpp.MessageDirection.MessageSent;
 			switch (value.status) {
 				case "MessagePending":
@@ -102,7 +97,7 @@ exports.xmpp.persistence = {
 				default:
 					message.status = message.serverId ? xmpp.MessageStatus.MessageDeliveredToServer : xmpp.MessageStatus.MessagePending;
 			}
-			message.versions = (value.versions || []).map(hydrateMessage);
+			message.versions = await Promise.all((value.versions || []).map(hydrateMessage));
 			message.payloads = (value.payloads || []).map(xmpp.Stanza.parse);
 			return message;
 		}
@@ -122,6 +117,7 @@ exports.xmpp.persistence = {
 				recipients: message.recipients.map((r) => r.asString()),
 				replyTo: message.replyTo.map((r) => r.asString()),
 				timestamp: new Date(message.timestamp),
+				replyToMessage: message.replyToMessage && [account, message.replyToMessage.serverId || "", message.replyToMessage.serverIdBy || "", message.replyToMessage.localId || ""],
 				direction: message.direction.toString(),
 				status: message.status.toString(),
 				versions: message.versions.map((m) => serializeMessage(account, m)),
@@ -129,6 +125,40 @@ exports.xmpp.persistence = {
 			}
 		}
 
+		function correctMessage(account, message, result) {
+			// Newest (by timestamp) version wins for head
+			const newVersions = message.versions.length < 1 ? [message] : message.versions;
+			const storedVersions = result.value.versions || [];
+			// TODO: dedupe? There shouldn't be dupes...
+			const versions = (storedVersions.length < 1 ? [result.value] : storedVersions).concat(newVersions.map((nv) => serializeMessage(account, nv))).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
+			const head = {...versions[0]};
+			// Can't change primary key
+			head.serverIdBy = result.value.serverIdBy;
+			head.serverId = result.value.serverId;
+			head.localId = result.value.localId;
+			head.timestamp = result.value.timestamp; // Edited version is not newer
+			head.versions = versions;
+			head.reactions = result.value.reactions; // Preserve these, edit doesn't touch them
+			result.update(head);
+			return head;
+		}
+
+		function setReactions(reactionsMap, sender, reactions) {
+			for (const [reaction, senders] of reactionsMap) {
+				if (!reactions.includes(reaction) && senders.includes(sender)) {
+					if (senders.length === 1) {
+						reactionsMap.delete(reaction);
+					} else {
+						reactionsMap.set(reaction, senders.filter((asender) => asender != sender));
+					}
+				}
+			}
+			for (const reaction of reactions) {
+				reactionsMap.set(reaction, [...new Set([...reactionsMap.get(reaction) || [], sender])].sort());
+			}
+			return reactionsMap;
+		}
+
 		return {
 			lastId: function(account, jid, callback) {
 				const tx = db.transaction(["messages"], "readonly");
@@ -220,17 +250,17 @@ exports.xmpp.persistence = {
 								if (readUpTo === value.serverId || readUpTo === value.localId || value.direction == "MessageSent") {
 									result[value.chatId].foundAll = true;
 								} else {
-									result[value.chatId].unreadCount++;
+									result[value.chatId] = result[value.chatId].then((details) => { details.unreadCount++; return details; });
 								}
 							}
 						} else {
 							const readUpTo = chats[value.chatId]?.readUpTo();
 							const haveRead = readUpTo === value.serverId || readUpTo === value.localId || value.direction == "MessageSent";
-							result[value.chatId] = { chatId: value.chatId, message: hydrateMessage(value), unreadCount: haveRead ? 0 : 1, foundAll: haveRead };
+							result[value.chatId] = hydrateMessage(value).then((m) => ({ chatId: value.chatId, message: m, unreadCount: haveRead ? 0 : 1, foundAll: haveRead }));
 						}
 						event.target.result.continue();
 					} else {
-						callback(Object.values(result));
+						Promise.all(Object.values(result)).then(callback);
 					}
 				}
 				cursor.onerror = (event) => {
@@ -239,20 +269,92 @@ exports.xmpp.persistence = {
 				}
 			},
 
-			storeMessage: function(account, message) {
-				const tx = db.transaction(["messages"], "readwrite");
+			getMessage: function(account, chatId, serverId, localId, callback) {
+				const tx = db.transaction(["messages"], "readonly");
 				const store = tx.objectStore("messages");
+				(async function() {
+					let result;
+					if (serverId) {
+						result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, serverId], [account, serverId, []])));
+					} else {
+						result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, localId, chatId])));
+					}
+					if (!result || !result.value) return null;
+					const message = result.value;
+					return await hydrateMessage(message);
+				})().then(callback);
+			},
+
+			storeReaction: function(account, update, callback) {
+				(async function() {
+					const tx = db.transaction(["messages", "reactions"], "readwrite");
+					const store = tx.objectStore("messages");
+					const reactionStore = tx.objectStore("reactions");
+					let result;
+					if (update.serverId) {
+						result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, update.serverId], [account, update.serverId, []])));
+					} else {
+						result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, update.localId, update.chatId])));
+					}
+					await promisifyRequest(reactionStore.put({...update, messageId: update.serverId || update.localId, timestamp: new Date(update.timestamp), account: account}));
+					if (!result || !result.value) {
+						return null;
+					}
+					const message = result.value;
+					const lastFromSender = promisifyRequest(reactionStore.index("senders").openCursor(IDBKeyRange.bound(
+						[account, update.chatId, update.serverId || update.localId, update.senderId],
+						[account, update.chatId, update.serverId || update.localId, update.senderId, []]
+					), "prev"));
+					if (lastFromSender?.value && lastFromSender.value.timestamp > new Date(update.timestamp)) return;
+					setReactions(message.reactions, update.senderId, update.reactions);
+					store.put(message);
+					return await hydrateMessage(message);
+				})().then(callback);
+			},
+
+			storeMessage: function(account, message, callback) {
 				if (!message.chatId()) throw "Cannot store a message with no chatId";
 				if (!message.serverId && !message.localId) throw "Cannot store a message with no id";
 				if (!message.serverId && message.isIncoming()) throw "Cannot store an incoming message with no server id";
 				if (message.serverId && !message.serverIdBy) throw "Cannot store a message with a server id and no by";
-				promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, message.localId || [], message.chatId()]))).then((result) => {
-					if (result?.value && !message.isIncoming() && result?.value.direction === "MessageSent") {
-						// Duplicate, we trust our own sent ids
-						return promisifyRequest(result.delete());
-					}
-				}).then(() => {
-					store.put(serializeMessage(account, message));
+				new Promise((resolve) =>
+					// Hydrate reply stubs
+					message.replyToMessage && !message.replyToMessage.serverIdBy ? this.getMessage(account, message.chatId(), message.replyToMessage?.serverId, message.replyToMessage?.localId, resolve) : resolve(message.replyToMessage)
+				).then((replyToMessage) => {
+					message.replyToMessage = replyToMessage;
+					const tx = db.transaction(["messages", "reactions"], "readwrite");
+					const store = tx.objectStore("messages");
+					return promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, message.localId || [], message.chatId()]))).then((result) => {
+						if (result?.value && !message.isIncoming() && result?.value.direction === "MessageSent") {
+							// Duplicate, we trust our own sent ids
+							return promisifyRequest(result.delete());
+						} else if (result?.value && result.value.sender == message.senderId() && (message.versions.length > 0 || (result.value.versions || []).length > 0)) {
+							hydrateMessage(correctMessage(account, message, result)).then(callback);
+							return true;
+						}
+					}).then((done) => {
+						if (!done) {
+							// There may be reactions already if we are paging backwards
+							const cursor = tx.objectStore("reactions").index("senders").openCursor(IDBKeyRange.bound([account, message.chatId(), (message.groupchat ? message.serverId : message.localId) || ""], [account, message.chatId(), (message.groupchat ? message.serverId : message.localId) || "", []]), "prev");
+							const reactions = new Map();
+							const reactionTimes = new Map();
+							cursor.onsuccess = (event) => {
+								if (event.target.result && event.target.result.value) {
+									const time = reactionTimes.get(event.target.result.senderId);
+									if (!time || time < event.target.result.value.timestamp) {
+										setReactions(reactions, event.target.result.value.senderId, event.target.result.value.reactions);
+										reactionTimes.set(event.target.result.value.senderId, event.target.result.value.timestamp);
+									}
+									event.target.result.continue();
+								} else {
+									message.reactions = reactions;
+									store.put(serializeMessage(account, message));
+									callback(message);
+								}
+							};
+							cursor.onerror = console.error;
+						}
+					});
 				});
 			},
 
@@ -263,28 +365,7 @@ exports.xmpp.persistence = {
 					if (result?.value && result.value.direction === "MessageSent" && result.value.status !== "MessageDeliveredToDevice") {
 						const newStatus = { ...result.value, status: status.toString() };
 						result.update(newStatus);
-						callback(hydrateMessage(newStatus));
-					}
-				});
-			},
-
-			correctMessage: function(account, localId, message, callback) {
-				const tx = db.transaction(["messages"], "readwrite");
-				const store = tx.objectStore("messages");
-				promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, localId, message.chatId()]))).then((result) => {
-					if (result?.value && result.value.sender == message.senderId()) {
-						// NOTE: this strategy loses the ids and timestamp of the replacement messages
-						const withAnnotation = serializeMessage(account, message);
-						withAnnotation.serverIdBy = result.value.serverIdBy;
-						withAnnotation.serverId = result.value.serverId;
-						withAnnotation.localId = result.value.localId;
-						withAnnotation.timestamp = result.value.timestamp; // Edited version is not newer
-						withAnnotation.versions = [{ ...result.value, versions: [] }].concat(result.value.versions || [])
-						result.update(withAnnotation);
-						callback(hydrateMessage(withAnnotation));
-					} else {
-						this.storeMessage(account, message);
-						callback(message);
+						hydrateMessage(newStatus).then(callback);
 					}
 				});
 			},
@@ -309,7 +390,7 @@ exports.xmpp.persistence = {
 						result.unshift(hydrateMessage(value));
 						event.target.result.continue();
 					} else {
-						callback(result);
+						Promise.all(result).then(callback);
 					}
 				}
 				cursor.onerror = (event) => {