git » sdk » commit 17b2e46

ChatMessageBuilder

author Stephen Paul Weber
2025-03-18 18:54:36 UTC
committer Stephen Paul Weber
2025-03-18 18:56:13 UTC
parent c64773334b5029f842888df3b827ea5174162cf0

ChatMessageBuilder

Split out the builder from an immutable ChatMessage

This changes the API in a few key ways, but means we don't need to worry
about required properties of ChatMessage ever being null and we can
store the stanza they came from without fear that we've accidentally
changed the properties to not match afterwards.

npm/index.ts +1 -0
snikket/Chat.hx +49 -34
snikket/ChatMessage.hx +110 -157
snikket/ChatMessageBuilder.hx +333 -0
snikket/Client.hx +12 -6
snikket/Message.hx +34 -29
snikket/MessageSync.hx +11 -8
snikket/Stanza.hx +1 -1
snikket/jingle/Session.hx +4 -4
snikket/persistence/IDB.js +11 -8
snikket/persistence/Sqlite.hx +59 -50

diff --git a/npm/index.ts b/npm/index.ts
index f067e2d..05db77a 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -10,6 +10,7 @@ export import Channel = snikket.Channel;
 export import Chat = snikket.Chat;
 export import ChatAttachment = snikket.ChatAttachment;
 export import ChatMessage = snikket.ChatMessage;
+export import ChatMessageBuilder = snikket.ChatMessageBuilder;
 export import Client = snikket.Client;
 export import Config = snikket.Config;
 export import CustomEmojiReaction = snikket.CustomEmojiReaction;
diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index c6431b3..ffd8036 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -91,7 +91,7 @@ abstract class Chat {
 	}
 
 	@:allow(snikket)
-	abstract private function prepareIncomingMessage(message:ChatMessage, stanza:Stanza):ChatMessage;
+	abstract private function prepareIncomingMessage(message:ChatMessageBuilder, stanza:Stanza):ChatMessageBuilder;
 
 	/**
 		Fetch a page of messages before some point
@@ -129,7 +129,7 @@ abstract class Chat {
 			for (m in messageList.messages) {
 				switch (m) {
 					case ChatMessageStanza(message):
-						chatMessages.push(prepareIncomingMessage(message, new Stanza("message", { from: message.senderId() })));
+						chatMessages.push(message);
 					case ReactionUpdateStanza(update):
 						persistence.storeReaction(client.accountId(), update, (m)->{});
 					case ModerateMessageStanza(action):
@@ -150,7 +150,7 @@ abstract class Chat {
 
 		@param message the ChatMessage to send
 	**/
-	abstract public function sendMessage(message:ChatMessage):Void;
+	abstract public function sendMessage(message:ChatMessageBuilder):Void;
 
 	/**
 		Signals that all messages up to and including this one have probably
@@ -186,7 +186,7 @@ abstract class Chat {
 		       must be the localId of the first version ever sent, not a subsequent correction
 		@param message the new ChatMessage to replace it with
 	**/
-	abstract public function correctMessage(localId:String, message:ChatMessage):Void;
+	abstract public function correctMessage(localId:String, message:ChatMessageBuilder):Void;
 
 	/**
 		Add new reaction to a message in this Chat
@@ -725,12 +725,12 @@ class DirectChat extends Chat {
 	}
 
 	@:allow(snikket)
-	private function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) {
+	private function prepareIncomingMessage(message:ChatMessageBuilder, stanza:Stanza) {
 		message.syncPoint = !syncing();
 		return message;
 	}
 
-	private function prepareOutgoingMessage(message:ChatMessage) {
+	private function prepareOutgoingMessage(message:ChatMessageBuilder) {
 		message.timestamp = message.timestamp ?? Date.format(std.Date.now());
 		message.direction = MessageSent;
 		message.from = client.jid;
@@ -741,17 +741,17 @@ class DirectChat extends Chat {
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
-	public function correctMessage(localId:String, message:ChatMessage) {
-		final toSend = prepareOutgoingMessage(message.clone());
+	public function correctMessage(localId:String, message:ChatMessageBuilder) {
+		final toSendId = message.localId;
 		message = prepareOutgoingMessage(message);
-		message.resetLocalId();
-		message.versions = [toSend]; // This is a correction
+		message.versions = [message.build()]; // This is a correction
 		message.localId = localId;
-		client.storeMessages([message], (corrected) -> {
-			toSend.versions = corrected[0].versions[corrected[0].versions.length - 1]?.localId == localId ? corrected[0].versions : [message];
+		client.storeMessages([message.build()], (corrected) -> {
+			message.versions = corrected[0].versions[corrected[0].versions.length - 1]?.localId == localId ? cast corrected[0].versions : [message.build()];
+			message.localId = toSendId;
 			for (recipient in message.recipients) {
 				message.to = recipient;
-				client.sendStanza(toSend.asStanza());
+				client.sendStanza(message.build().asStanza());
 			}
 			if (localId == lastMessage?.localId) {
 				setLastMessage(corrected[0]);
@@ -762,17 +762,18 @@ class DirectChat extends Chat {
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
-	public function sendMessage(message:ChatMessage):Void {
+	public function sendMessage(message: ChatMessageBuilder):Void {
 		if (typingTimer != null) typingTimer.stop();
 		client.chatActivity(this);
 		message = prepareOutgoingMessage(message);
-		final fromStanza = Message.fromStanza(message.asStanza(), client.jid).parsed;
+		message.to = message.recipients[0]; // Just pick one for the stanza we re-parse
+		final fromStanza = Message.fromStanza(message.build().asStanza(), client.jid).parsed;
 		switch (fromStanza) {
 			case ChatMessageStanza(_):
-				client.storeMessages([message], (stored) -> {
+				client.storeMessages([message.build()], (stored) -> {
 					for (recipient in message.recipients) {
 						message.to = recipient;
-						final stanza = message.asStanza();
+						final stanza = message.build().asStanza();
 						if (isActive != null) {
 							isActive = true;
 							activeThread = message.threadId;
@@ -780,7 +781,7 @@ class DirectChat extends Chat {
 						}
 						client.sendStanza(stanza);
 					}
-					setLastMessage(message);
+					setLastMessage(message.build());
 					client.trigger("chats/update", [this]);
 					client.notifyMessageHandlers(stored[0], stored[0].versions.length > 1 ? CorrectionEvent : DeliveryEvent);
 				});
@@ -788,7 +789,7 @@ class DirectChat extends Chat {
 				persistence.storeReaction(client.accountId(), update, (stored) -> {
 					for (recipient in message.recipients) {
 						message.to = recipient;
-						client.sendStanza(message.asStanza());
+						client.sendStanza(message.build().asStanza());
 					}
 					if (stored != null) client.notifyMessageHandlers(stored, ReactionEvent);
 				});
@@ -1032,6 +1033,11 @@ class Channel extends Chat {
 			chatId
 		);
 		sync.setNewestPageFirst(false);
+		sync.addContext((builder, stanza) -> {
+			builder = prepareIncomingMessage(builder, stanza);
+			builder.syncPoint = true;
+			return builder;
+		});
 		final chatMessages = [];
 		sync.onMessages((messageList) -> {
 			final promises = [];
@@ -1042,7 +1048,6 @@ class Channel extends Chat {
 						for (hash in message.inlineHashReferences()) {
 							client.fetchMediaByHash([hash], [message.from]);
 						}
-						message.syncPoint = true;
 						pageChatMessages.push(message);
 					case ReactionUpdateStanza(update):
 						promises.push(new thenshim.Promise((resolve, reject) -> {
@@ -1131,7 +1136,7 @@ class Channel extends Chat {
 	override public function preview() {
 		if (lastMessage == null) return super.preview();
 
-		return getParticipantDetails(lastMessage.senderId()).displayName + ": " + super.preview();
+		return getParticipantDetails(lastMessage.senderId).displayName + ": " + super.preview();
 	}
 
 	@:allow(snikket)
@@ -1188,6 +1193,11 @@ 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.addContext((builder, stanza) -> {
+					builder = prepareIncomingMessage(builder, stanza);
+					builder.syncPoint = false;
+					return builder;
+				});
 				fetchFromSync(sync, handler);
 			}
 		});
@@ -1206,6 +1216,11 @@ class Channel extends Chat {
 				var filter:MAMQueryParams = {};
 				if (afterId != null) filter.page = { after: afterId };
 				var sync = new MessageSync(this.client, this.stream, filter, chatId);
+				sync.addContext((builder, stanza) -> {
+					builder = prepareIncomingMessage(builder, stanza);
+					builder.syncPoint = false;
+					return builder;
+				});
 				fetchFromSync(sync, handler);
 			}
 		});
@@ -1224,18 +1239,18 @@ class Channel extends Chat {
 	}
 
 	@:allow(snikket)
-	private function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) {
+	private function prepareIncomingMessage(message:ChatMessageBuilder, stanza:Stanza) {
 		message.syncPoint = !syncing();
 		if (message.type == MessageChat) message.type = MessageChannelPrivate;
 		message.sender = JID.parse(stanza.attr.get("from")); // MUC always needs full JIDs
-		if (message.senderId() == getFullJid().asString()) {
+		if (message.senderId == getFullJid().asString()) {
 			message.recipients = message.replyTo;
 			message.direction = MessageSent;
 		}
 		return message;
 	}
 
-	private function prepareOutgoingMessage(message:ChatMessage) {
+	private function prepareOutgoingMessage(message:ChatMessageBuilder) {
 		message.type = MessageChannel;
 		message.timestamp = message.timestamp ?? Date.format(std.Date.now());
 		message.direction = MessageSent;
@@ -1248,15 +1263,15 @@ class Channel extends Chat {
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
-	public function correctMessage(localId:String, message:ChatMessage) {
-		final toSend = prepareOutgoingMessage(message.clone());
+	public function correctMessage(localId:String, message:ChatMessageBuilder) {
+		final toSendId = message.localId;
 		message = prepareOutgoingMessage(message);
-		message.resetLocalId();
-		message.versions = [toSend]; // This is a correction
+		message.versions = [message.build()]; // This is a correction
 		message.localId = localId;
-		client.storeMessages([message], (corrected) -> {
-			toSend.versions = corrected[0].localId == localId ? corrected[0].versions : [message];
-			client.sendStanza(toSend.asStanza());
+		client.storeMessages([message.build()], (corrected) -> {
+			message.versions = corrected[0].localId == localId ? cast corrected[0].versions : [message.build()];
+			message.localId = toSendId;
+			client.sendStanza(message.build().asStanza());
 			client.notifyMessageHandlers(corrected[0], CorrectionEvent);
 			if (localId == lastMessage?.localId) {
 				setLastMessage(corrected[0]);
@@ -1266,11 +1281,11 @@ class Channel extends Chat {
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
-	public function sendMessage(message:ChatMessage):Void {
+	public function sendMessage(message:ChatMessageBuilder):Void {
 		if (typingTimer != null) typingTimer.stop();
 		client.chatActivity(this);
 		message = prepareOutgoingMessage(message);
-		final stanza = message.asStanza();
+		final stanza = message.build().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).parsed;
@@ -1282,7 +1297,7 @@ class Channel extends Chat {
 					activeThread = message.threadId;
 					stanza.tag("active", { xmlns: "http://jabber.org/protocol/chatstates" }).up();
 				}
-				client.storeMessages([message], (stored) -> {
+				client.storeMessages([message.build()], (stored) -> {
 					client.sendStanza(stanza);
 					setLastMessage(stored[0]);
 					client.notifyMessageHandlers(stored[0], stored[0].versions.length > 1 ? CorrectionEvent : DeliveryEvent);
diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx
index 25d3cfa..daeed14 100644
--- a/snikket/ChatMessage.hx
+++ b/snikket/ChatMessage.hx
@@ -1,10 +1,11 @@
 package snikket;
 
 import datetime.DateTime;
+import haxe.Exception;
 import haxe.crypto.Base64;
+import haxe.ds.ReadOnlyArray;
 import haxe.io.Bytes;
 import haxe.io.BytesData;
-import haxe.Exception;
 using Lambda;
 using StringTools;
 
@@ -23,7 +24,7 @@ import snikket.Stanza;
 import snikket.Util;
 
 @:expose
-@:nullSafety(Strict)
+@:nullSafety(StrictThreaded)
 #if cpp
 @:build(HaxeCBridge.expose())
 @:build(HaxeSwiftBridge.expose())
@@ -32,8 +33,8 @@ class ChatAttachment {
 	public final name: Null<String>;
 	public final mime: String;
 	public final size: Null<Int>;
-	public final uris: Array<String>;
-	public final hashes: Array<Hash>;
+	public final uris: ReadOnlyArray<String>;
+	public final hashes: ReadOnlyArray<Hash>;
 
 	#if cpp
 	@:allow(snikket)
@@ -51,7 +52,7 @@ class ChatAttachment {
 }
 
 @:expose
-@:nullSafety(Strict)
+@:nullSafety(StrictThreaded)
 #if cpp
 @:build(HaxeCBridge.expose())
 @:build(HaxeSwiftBridge.expose())
@@ -60,94 +61,155 @@ class ChatMessage {
 	/**
 		The ID as set by the creator of this message
 	**/
-	public var localId (default, set) : Null<String> = null;
+	public final localId: Null<String>;
+
 	/**
 		The ID as set by the authoritative server
 	**/
-	public var serverId (default, set) : Null<String> = null;
+	public final serverId: Null<String>;
+
 	/**
 		The ID of the server which set the serverId
 	**/
-	public var serverIdBy : Null<String> = null;
+	public final serverIdBy: Null<String>;
+
 	/**
 		The type of this message (Chat, Call, etc)
 	**/
-	public var type : MessageType = MessageChat;
+	public final type: MessageType;
 
 	@:allow(snikket)
-	private var syncPoint : Bool = false;
+	private final syncPoint : Bool;
 
 	@:allow(snikket)
-	private var replyId : Null<String> = null;
+	private final replyId : Null<String>;
 
 	/**
-		The timestamp of this message, in format YYYY-MM-DDThh:mm:ss[.sss]+00:00
+		The timestamp of this message, in format YYYY-MM-DDThh:mm:ss[.sss]Z
 	**/
-	public var timestamp (default, set) : Null<String> = null;
+	public final timestamp: String;
 
 	@:allow(snikket)
-	private var to: Null<JID> = null;
-	@:allow(snikket)
-	private var from: Null<JID> = null;
+	private final to: JID;
 	@:allow(snikket)
-	private var sender: Null<JID> = null;
+	private final from: JID;
 	@:allow(snikket)
-	private var recipients: Array<JID> = [];
+	private final recipients: ReadOnlyArray<JID>;
 	@:allow(snikket)
-	private var replyTo: Array<JID> = [];
+	private final replyTo: ReadOnlyArray<JID>;
+
+	/**
+		The ID of the sender of this message
+	**/
+	public final senderId: String;
 
 	/**
 		Message this one is in reply to, or NULL
 	**/
-	public var replyToMessage: Null<ChatMessage> = null;
+	public var replyToMessage(default, null): Null<ChatMessage>;
+
 	/**
 		ID of the thread this message is in, or NULL
 	**/
-	public var threadId: Null<String> = null;
+	public final threadId: Null<String>;
 
 	/**
 		Array of attachments to this message
 	**/
-	public var attachments (default, null): Array<ChatAttachment> = [];
+	public final attachments: ReadOnlyArray<ChatAttachment>;
+
 	/**
 		Map of reactions to this message
 	**/
 	@HaxeCBridge.noemit
-	public var reactions: Map<String, Array<Reaction>> = [];
+	public var reactions(default, null): Map<String, Array<Reaction>>;
 
 	/**
 		Body text of this message or NULL
 	**/
-	public var text: Null<String> = null;
+	public final text: Null<String>;
+
 	/**
 		Language code for the body text
 	**/
-	public var lang: Null<String> = null;
+	public final lang: Null<String>;
 
 	/**
 		Direction of this message
 	**/
-	public var direction: MessageDirection = MessageReceived;
+	public final direction: MessageDirection;
+
 	/**
 		Status of this message
 	**/
-	public var status: MessageStatus = MessagePending;
+	public var status: MessageStatus;
+
 	/**
 		Array of past versions of this message, if it has been edited
 	**/
 	@:allow(snikket)
-	public var versions (default, null): Array<ChatMessage> = [];
+	public final versions: ReadOnlyArray<ChatMessage>;
+
 	@:allow(snikket, test)
-	private var payloads: Array<Stanza> = [];
+	private final payloads: ReadOnlyArray<Stanza>;
 
-	/**
-		@returns a new blank ChatMessage
-	**/
-	public function new() { }
+	@:allow(snikket)
+	public final stanza: Null<Stanza>;
 
 	@:allow(snikket)
-	private static function fromStanza(stanza:Stanza, localJid:JID):Null<ChatMessage> {
-		switch Message.fromStanza(stanza, localJid).parsed {
+	private function new(params: {
+		?localId: Null<String>,
+		?serverId: Null<String>,
+		?serverIdBy: Null<String>,
+		?type: MessageType,
+		?syncPoint: Bool,
+		?replyId: Null<String>,
+		timestamp: String,
+		to: JID,
+		from: JID,
+		senderId: String,
+		?recipients: Array<JID>,
+		?replyTo: Array<JID>,
+		?replyToMessage: Null<ChatMessage>,
+		?threadId: Null<String>,
+		?attachments: Array<ChatAttachment>,
+		?reactions: Map<String, Array<Reaction>>,
+		?text: Null<String>,
+		?lang: Null<String>,
+		?direction: MessageDirection,
+		?status: MessageStatus,
+		?versions: Array<ChatMessage>,
+		?payloads: Array<Stanza>,
+		?stanza: Null<Stanza>,
+	}) {
+		this.localId = params.localId;
+		this.serverId = params.serverId;
+		this.serverIdBy = params.serverIdBy;
+		this.type = params.type ?? MessageChat;
+		this.syncPoint = params.syncPoint ?? false;
+		this.replyId = params.replyId;
+		this.timestamp = params.timestamp;
+		this.to = params.to;
+		this.from = params.from;
+		this.senderId = params.senderId;
+		this.recipients = params.recipients ?? [];
+		this.replyTo = params.replyTo ?? [];
+		this.replyToMessage = params.replyToMessage;
+		this.threadId = params.threadId;
+		this.attachments = params.attachments ?? [];
+		this.reactions = params.reactions ?? ([] : Map<String, Array<Reaction>>);
+		this.text = params.text;
+		this.lang = params.lang;
+		this.direction = params.direction ?? MessageSent;
+		this.status = params.status ?? MessagePending;
+		this.versions = params.versions ?? [];
+		this.payloads = params.payloads ?? [];
+		this.stanza = params.stanza;
+	}
+
+	@:allow(snikket)
+	private static function fromStanza(stanza:Stanza, localJid:JID, ?addContext: (ChatMessageBuilder, Stanza)->ChatMessageBuilder):Null<ChatMessage> {
+		switch Message.fromStanza(stanza, localJid, addContext).parsed {
 			case ChatMessageStanza(message):
 				return message;
 			default:
@@ -155,31 +217,11 @@ class ChatMessage {
 		}
 	}
 
-	@:allow(snikket)
-	private function attachSims(sims: Stanza) {
-		var mime = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/media-type#");
-		if (mime == null) mime = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/media-type#");
-		if (mime == null) mime = "application/octet-stream";
-		var name = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/name#");
-		if (name == null) name = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/name#");
-		var size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/size#");
-		if (size == null) size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/size#");
-		final hashes = ((sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:5") ?? sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:3"))
-			?.allTags("hash", "urn:xmpp:hashes:2") ?? []).map((hash) -> new Hash(hash.attr.get("algo") ?? "", Base64.decode(hash.getText()).getData()));
-		final sources = sims.getChild("sources");
-		final uris = (sources?.allTags("reference", "urn:xmpp:reference:0") ?? []).map((ref) -> ref.attr.get("uri") ?? "").filter((uri) -> uri != "");
-		if (uris.length > 0) attachments.push(new ChatAttachment(name, mime, size == null ? null : Std.parseInt(size), uris, hashes));
-	}
-
-	public function addAttachment(attachment: ChatAttachment) {
-		attachments.push(attachment);
-	}
-
 	/**
 		Create a new ChatMessage in reply to this one
 	**/
 	public function reply() {
-		final m = new ChatMessage();
+		final m = new ChatMessageBuilder();
 		m.type = type;
 		m.threadId = threadId ?? ID.long();
 		m.replyToMessage = this;
@@ -192,42 +234,18 @@ class ChatMessage {
 	}
 
 	@:allow(snikket)
-	private function makeModerated(timestamp: String, moderatorId: Null<String>, reason: Null<String>) {
-		text = null;
-		attachments = [];
-		payloads = [];
-		versions = [];
-		final cleanedStub = clone();
-		final payload = new Stanza("retracted", { xmlns: "urn:xmpp:message-retract:1", stamp: timestamp });
-		if (reason != null) payload.textTag("reason", reason);
-		payload.tag("moderated", { by: moderatorId, xmlns: "urn:xmpp:message-moderate:1" }).up();
-		payloads.push(payload);
-		final head = clone();
-		head.timestamp = timestamp;
-		versions = [head, cleanedStub];
-	}
-
-	private function set_localId(localId:Null<String>) {
-		if(this.localId != null) {
-			throw new Exception("Message already has a localId set");
-		}
-		return this.localId = localId;
-	}
-
-	private function set_serverId(serverId:Null<String>) {
-		if(this.serverId != null && this.serverId != serverId) {
-			throw new Exception("Message already has a serverId set");
-		}
-		return this.serverId = serverId;
-	}
-
-	private function set_timestamp(timestamp:Null<String>) {
-		return this.timestamp = timestamp;
+	private function set_replyToMessage(m: ChatMessage) {
+		final rtm = replyToMessage;
+		if (rtm == null) throw "Cannot hydrate null replyToMessage";
+		if (rtm.serverId != null && rtm.serverId != m.serverId) throw "Hydrate serverId mismatch";
+		if (rtm.localId != null && rtm.localId != m.localId) throw "Hydrate localId mismatch";
+		return replyToMessage = m;
 	}
 
 	@:allow(snikket)
-	private function resetLocalId() {
-		Reflect.setField(this, "localId", null);
+	private function set_reactions(r: Map<String, Array<Reaction>>) {
+		if (reactions != null && !{ iterator: () -> reactions.keys() }.empty()) throw "Reactions already hydrated";
+		return reactions = r;
 	}
 
 	@:allow(snikket)
@@ -293,50 +311,6 @@ class ChatMessage {
 		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);
 	}
 
-	/**
-		Set rich text using an HTML string
-		Also sets the plain text body appropriately
-	**/
-	public function setHtml(html: String) {
-		final htmlEl = new Stanza("html", { xmlns: "http://jabber.org/protocol/xhtml-im" });
-		final body = new Stanza("body", { xmlns: "http://www.w3.org/1999/xhtml" });
-		htmlEl.addChild(body);
-		final nodes = htmlparser.HtmlParser.run(html, true);
-		for (node in nodes) {
-			final el = Util.downcast(node, htmlparser.HtmlNodeElement);
-			if (el != null && (el.name == "html" || el.name == "body")) {
-				for (inner in el.nodes) {
-					body.addDirectChild(htmlToNode(inner));
-				}
-			} else {
-				body.addDirectChild(htmlToNode(node));
-			}
-		}
-		final htmlIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html");
-		if (htmlIdx >= 0) payloads.splice(htmlIdx, 1);
-		payloads.push(htmlEl);
-		text = XEP0393.render(body);
-	}
-
-	private function htmlToNode(node: htmlparser.HtmlNode) {
-		final txt = Util.downcast(node, htmlparser.HtmlNodeText);
-		if (txt != null) {
-			return CData(new TextNode(txt.toText()));
-		}
-		final el = Util.downcast(node, htmlparser.HtmlNodeElement);
-		if (el != null) {
-			final s = new Stanza(el.name, {});
-			for (attr in el.attributes) {
-				s.attr.set(attr.name, attr.value);
-			}
-			for (child in el.nodes) {
-				s.addDirectChild(htmlToNode(child));
-			}
-			return Element(s);
-		}
-		throw "node was neither text nor element?";
-	}
-
 	/**
 		The ID of the Chat this message is associated with
 	**/
@@ -348,13 +322,6 @@ class ChatMessage {
 		}
 	}
 
-	/**
-		The ID of the sender of this message
-	**/
-	public function senderId():String {
-		return sender?.asString() ?? throw "sender is null";
-	}
-
 	/**
 		The ID of the account associated with this message
 	**/
@@ -418,6 +385,8 @@ class ChatMessage {
 
 	@:allow(snikket)
 	private function asStanza():Stanza {
+		if (stanza != null) return stanza;
+
 		var body = text;
 		var attrs: haxe.DynamicAccess<String> = { type: type == MessageChannel ? "groupchat" : "chat" };
 		if (from != null) attrs.set("from", from.asString());
@@ -462,7 +431,7 @@ class ChatMessage {
 						addedReactions[reaction] = true;
 
 						for (areaction => reactions in replyToM.reactions) {
-							if (!(addedReactions[areaction] ?? false) && reactions.find(r -> r.senderId == senderId()) != null) {
+							if (!(addedReactions[areaction] ?? false) && reactions.find(r -> r.senderId == senderId) != null) {
 								addedReactions[areaction] = true;
 								stanza.textTag("reaction", areaction);
 							}
@@ -521,20 +490,4 @@ class ChatMessage {
 		}
 		return stanza;
 	}
-
-	/**
-		Duplicate this ChatMessage
-	**/
-	public function clone() {
-		final cls:Class<ChatMessage> = untyped Type.getClass(this);
-		final inst = Type.createEmptyInstance(cls);
-		final fields = Type.getInstanceFields(cls);
-		for (field in fields) {
-			final val:Dynamic = Reflect.field(this, field);
-			if (!Reflect.isFunction(val)) {
-				Reflect.setField(inst,field,val);
-			}
-		}
-		return inst;
-	}
 }
diff --git a/snikket/ChatMessageBuilder.hx b/snikket/ChatMessageBuilder.hx
new file mode 100644
index 0000000..7e255ee
--- /dev/null
+++ b/snikket/ChatMessageBuilder.hx
@@ -0,0 +1,333 @@
+package snikket;
+
+import datetime.DateTime;
+import haxe.crypto.Base64;
+import haxe.io.Bytes;
+import haxe.io.BytesData;
+import haxe.Exception;
+using Lambda;
+using StringTools;
+
+#if cpp
+import HaxeCBridge;
+#end
+
+import snikket.Hash;
+import snikket.JID;
+import snikket.Identicon;
+import snikket.StringUtil;
+import snikket.XEP0393;
+import snikket.EmojiUtil;
+import snikket.Message;
+import snikket.Stanza;
+import snikket.Util;
+import snikket.ChatMessage;
+
+@:expose
+@:nullSafety(StrictThreaded)
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class ChatMessageBuilder {
+	/**
+		The ID as set by the creator of this message
+	**/
+	public var localId: Null<String> = null;
+
+	/**
+		The ID as set by the authoritative server
+	**/
+	public var serverId: Null<String> = null;
+
+	/**
+		The ID of the server which set the serverId
+	**/
+	public var serverIdBy: Null<String> = null;
+
+	/**
+		The type of this message (Chat, Call, etc)
+	**/
+	public var type: MessageType = MessageChat;
+
+	@:allow(snikket)
+	private var syncPoint: Bool = false;
+
+	@:allow(snikket)
+	private var replyId: Null<String> = null;
+
+	/**
+		The timestamp of this message, in format YYYY-MM-DDThh:mm:ss[.sss]+00:00
+	**/
+	public var timestamp: Null<String> = null;
+
+	@:allow(snikket)
+	private var to: Null<JID> = null;
+	@:allow(snikket)
+	private var from: Null<JID> = null;
+	@:allow(snikket)
+	private var sender: Null<JID> = null; // DEPRECATED
+	@:allow(snikket)
+	private var recipients: Array<JID> = [];
+	@:allow(snikket)
+	private var replyTo: Array<JID> = [];
+
+	public var senderId (get, default): Null<String> = null;
+
+	/**
+		Message this one is in reply to, or NULL
+	**/
+	public var replyToMessage: Null<ChatMessage> = null;
+
+	/**
+		ID of the thread this message is in, or NULL
+	**/
+	public var threadId: Null<String> = null;
+
+	/**
+		Array of attachments to this message
+	**/
+	public var attachments (default, null): Array<ChatAttachment> = [];
+
+	/**
+		Map of reactions to this message
+	**/
+	@HaxeCBridge.noemit
+	public var reactions: Map<String, Array<Reaction>> = [];
+
+	/**
+		Body text of this message or NULL
+	**/
+	public var text: Null<String> = null;
+
+	/**
+		Language code for the body text
+	**/
+	public var lang: Null<String> = null;
+
+	/**
+		Direction of this message
+	**/
+	public var direction: MessageDirection = MessageReceived;
+
+	/**
+		Status of this message
+	**/
+	public var status: MessageStatus = MessagePending;
+
+	/**
+		Array of past versions of this message, if it has been edited
+	**/
+	@:allow(snikket)
+	public var versions (default, null): Array<ChatMessage> = [];
+
+	@:allow(snikket, test)
+	private var payloads: Array<Stanza> = [];
+
+	/**
+		WARNING: if you set this, you promise all the attributes of this builder match it
+	**/
+	@:allow(snikket)
+	private var stanza: Null<Stanza> = null;
+
+	/**
+		@returns a new blank ChatMessageBuilder
+	**/
+	#if cpp
+	public function new() { }
+	#else
+	public function new(?params: {
+		?localId: Null<String>,
+		?serverId: Null<String>,
+		?serverIdBy: Null<String>,
+		?type: MessageType,
+		?syncPoint: Bool,
+		?replyId: Null<String>,
+		?timestamp: String,
+		?senderId: String,
+		?replyToMessage: Null<ChatMessage>,
+		?threadId: Null<String>,
+		?attachments: Array<ChatAttachment>,
+		?reactions: Map<String, Array<Reaction>>,
+		?text: Null<String>,
+		?lang: Null<String>,
+		?direction: MessageDirection,
+		?status: MessageStatus,
+		?versions: Array<ChatMessage>,
+		?payloads: Array<Stanza>,
+		?html: Null<String>,
+	}) {
+		this.localId = params?.localId;
+		this.serverId = params?.serverId;
+		this.serverIdBy = params?.serverIdBy;
+		this.type = params?.type ?? MessageChat;
+		this.syncPoint = params?.syncPoint ?? false;
+		this.replyId = params?.replyId;
+		this.timestamp = params?.timestamp;
+		this.senderId = params?.senderId;
+		this.replyToMessage = params?.replyToMessage;
+		this.threadId = params?.threadId;
+		this.attachments = params?.attachments ?? [];
+		this.reactions = params?.reactions ?? ([] : Map<String, Array<Reaction>>);
+		this.text = params?.text;
+		this.lang = params?.lang;
+		this.direction = params?.direction ?? MessageSent;
+		this.status = params?.status ?? MessagePending;
+		this.versions = params?.versions ?? [];
+		this.payloads = params?.payloads ?? [];
+		final html = params?.html;
+		if (html != null) setHtml(html);
+	}
+	#end
+
+	@:allow(snikket)
+	private static function makeModerated(m: ChatMessage, timestamp: String, moderatorId: Null<String>, reason: Null<String>) {
+		final builder = new ChatMessageBuilder();
+		builder.localId = m.localId;
+		builder.serverId = m.serverId;
+		builder.serverIdBy = m.serverIdBy;
+		builder.type = m.type;
+		builder.syncPoint = m.syncPoint;
+		builder.replyId = m.replyId;
+		builder.timestamp = m.timestamp;
+		builder.to = m.to;
+		builder.from = m.from;
+		builder.senderId = m.senderId;
+		builder.recipients = m.recipients.array();
+		builder.replyTo = m.replyTo.array();
+		builder.replyToMessage = m.replyToMessage;
+		builder.threadId = m.threadId;
+		builder.reactions = m.reactions;
+		builder.direction = m.direction;
+		builder.status = m.status;
+		final cleanedStub = builder.build();
+		final payload = new Stanza("retracted", { xmlns: "urn:xmpp:message-retract:1", stamp: timestamp });
+		if (reason != null) payload.textTag("reason", reason);
+		payload.tag("moderated", { by: moderatorId, xmlns: "urn:xmpp:message-moderate:1" }).up();
+		builder.payloads.push(payload);
+		builder.timestamp = timestamp;
+		builder.versions = [builder.build(), cleanedStub];
+		builder.timestamp = m.timestamp;
+		return builder.build();
+	}
+
+	@:allow(snikket)
+	private function attachSims(sims: Stanza) {
+		var mime = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/media-type#");
+		if (mime == null) mime = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/media-type#");
+		if (mime == null) mime = "application/octet-stream";
+		var name = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/name#");
+		if (name == null) name = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/name#");
+		var size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/size#");
+		if (size == null) size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/size#");
+		final hashes = ((sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:5") ?? sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:3"))
+			?.allTags("hash", "urn:xmpp:hashes:2") ?? []).map((hash) -> new Hash(hash.attr.get("algo") ?? "", Base64.decode(hash.getText()).getData()));
+		final sources = sims.getChild("sources");
+		final uris = (sources?.allTags("reference", "urn:xmpp:reference:0") ?? []).map((ref) -> ref.attr.get("uri") ?? "").filter((uri) -> uri != "");
+		if (uris.length > 0) attachments.push(new ChatAttachment(name, mime, size == null ? null : Std.parseInt(size), uris, hashes));
+	}
+
+	public function addAttachment(attachment: ChatAttachment) {
+		attachments.push(attachment);
+	}
+
+	/**
+		Set rich text using an HTML string
+		Also sets the plain text body appropriately
+	**/
+	public function setHtml(html: String) {
+		final htmlEl = new Stanza("html", { xmlns: "http://jabber.org/protocol/xhtml-im" });
+		final body = new Stanza("body", { xmlns: "http://www.w3.org/1999/xhtml" });
+		htmlEl.addChild(body);
+		final nodes = htmlparser.HtmlParser.run(html, true);
+		for (node in nodes) {
+			final el = Util.downcast(node, htmlparser.HtmlNodeElement);
+			if (el != null && (el.name == "html" || el.name == "body")) {
+				for (inner in el.nodes) {
+					body.addDirectChild(htmlToNode(inner));
+				}
+			} else {
+				body.addDirectChild(htmlToNode(node));
+			}
+		}
+		final htmlIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html");
+		if (htmlIdx >= 0) payloads.splice(htmlIdx, 1);
+		payloads.push(htmlEl);
+		text = XEP0393.render(body);
+	}
+
+	private function htmlToNode(node: htmlparser.HtmlNode) {
+		final txt = Util.downcast(node, htmlparser.HtmlNodeText);
+		if (txt != null) {
+			return CData(new TextNode(txt.toText()));
+		}
+		final el = Util.downcast(node, htmlparser.HtmlNodeElement);
+		if (el != null) {
+			final s = new Stanza(el.name, {});
+			for (attr in el.attributes) {
+				s.attr.set(attr.name, attr.value);
+			}
+			for (child in el.nodes) {
+				s.addDirectChild(htmlToNode(child));
+			}
+			return Element(s);
+		}
+		throw "node was neither text nor element?";
+	}
+
+  	/**
+		The ID of the Chat this message is associated with
+	**/
+	public function chatId():String {
+		if (isIncoming()) {
+			return replyTo.map((r) -> r.asBare().asString()).join("\n");
+		} else {
+			return recipients.map((r) -> r.asString()).join("\n");
+		}
+	}
+
+	/**
+		The ID of the sender of this message
+	**/
+	public function get_senderId():String {
+		return senderId ?? sender?.asString() ?? throw "sender is null";
+	}
+
+	public function isIncoming():Bool {
+		return direction == MessageReceived;
+	}
+
+	public function build() {
+		if (serverId == null && localId == null) throw "Cannot build a ChatMessage with no id";
+		final to = this.to;
+		if (to == null) throw "Cannot build a ChatMessage with no to";
+		final from = this.from;
+		if (from == null) throw "Cannot build a ChatMessage with no from";
+		final sender = this.sender ?? from.asBare();
+		return new ChatMessage({
+			localId: localId,
+			serverId: serverId,
+			serverIdBy: serverIdBy,
+			type: type,
+			syncPoint: syncPoint,
+			replyId: replyId,
+			timestamp: timestamp ?? Date.format(std.Date.now()),
+			to: to,
+			from: from,
+			senderId: senderId,
+			recipients: recipients,
+			replyTo: replyTo,
+			replyToMessage: replyToMessage,
+			threadId: threadId,
+			attachments: attachments,
+			reactions: reactions,
+			text: text,
+			lang: lang,
+			direction: direction,
+			status: status,
+			versions: versions,
+			payloads: payloads,
+			stanza: stanza,
+		});
+	}
+}
diff --git a/snikket/Client.hx b/snikket/Client.hx
index be501f3..f204c03 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -162,14 +162,18 @@ class Client extends EventEmitter {
 				}
 			}
 
-			final message = Message.fromStanza(stanza, this.jid);
+			final message = Message.fromStanza(stanza, this.jid, (builder, stanza) -> {
+				var chat = getChat(builder.chatId());
+				if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(builder.chatId());
+				if (chat == null) return builder;
+				return chat.prepareIncomingMessage(builder, stanza);
+			});
 			switch (message.parsed) {
 				case ChatMessageStanza(chatMessage):
 					for (hash in chatMessage.inlineHashReferences()) {
 						fetchMediaByHash([hash], [chatMessage.from]);
 					}
-					var chat = getChat(chatMessage.chatId());
-					if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId());
+					final chat = getChat(chatMessage.chatId());
 					if (chat != null) {
 						final updateChat = (chatMessage) -> {
 							notifyMessageHandlers(chatMessage, chatMessage.versions.length > 1 ? CorrectionEvent : DeliveryEvent);
@@ -179,7 +183,6 @@ class Client extends EventEmitter {
 								chatActivity(chat);
 							}
 						};
-						chatMessage = chat.prepareIncomingMessage(chatMessage, stanza);
 						if (chatMessage.serverId == null) {
 							updateChat(chatMessage);
 						} else {
@@ -915,7 +918,7 @@ class Client extends EventEmitter {
 						persistence.removeMedia(hash.algorithm, hash.hash);
 					}
 				}
-				moderateMessage.makeModerated(action.timestamp, action.moderatorId, action.reason);
+				moderateMessage = ChatMessageBuilder.makeModerated(moderateMessage, action.timestamp, action.moderatorId, action.reason);
 				persistence.updateMessage(accountId(), moderateMessage);
 				resolve(moderateMessage);
 			})
@@ -1435,13 +1438,16 @@ class Client extends EventEmitter {
 			lastId == null ? { startTime: thirtyDaysAgo } : { page: { after: lastId } }
 		);
 		sync.setNewestPageFirst(false);
+		sync.addContext((builder, stanza) -> {
+			builder.syncPoint = true;
+			return builder;
+		});
 		sync.onMessages((messageList) -> {
 			final promises = [];
 			final chatMessages = [];
 			for (m in messageList.messages) {
 				switch (m) {
 					case ChatMessageStanza(message):
-						message.syncPoint = true;
 						chatMessages.push(message);
 					case ReactionUpdateStanza(update):
 						promises.push(new thenshim.Promise((resolve, reject) -> {
diff --git a/snikket/Message.hx b/snikket/Message.hx
index 620eafa..860d1ed 100644
--- a/snikket/Message.hx
+++ b/snikket/Message.hx
@@ -45,14 +45,14 @@ class Message {
 		this.parsed = parsed;
 	}
 
-	public static function fromStanza(stanza:Stanza, localJid:JID, ?inputTimestamp: String):Message {
+	public static function fromStanza(stanza:Stanza, localJid:JID, ?addContext: (ChatMessageBuilder, Stanza)->ChatMessageBuilder):Message {
 		final fromAttr = stanza.attr.get("from");
 		final from = fromAttr == null ? localJid.domain : fromAttr;
 		if (stanza.attr.get("type") == "error") return new Message(from, from, null, ErrorMessageStanza(stanza));
 
-		var msg = new ChatMessage();
-		final timestamp = stanza.findText("{urn:xmpp:delay}delay@stamp") ?? inputTimestamp ?? Date.format(std.Date.now());
-		msg.timestamp = timestamp;
+		var msg = new ChatMessageBuilder();
+		msg.stanza = stanza;
+		msg.timestamp =stanza.findText("{urn:xmpp:delay}delay@stamp");
 		msg.threadId = stanza.getChildText("thread");
 		msg.lang = stanza.attr.get("xml:lang");
 		msg.text = stanza.getChildText("body");
@@ -62,7 +62,7 @@ class Message {
 		msg.from = JID.parse(from);
 		final isGroupchat = stanza.attr.get("type") == "groupchat";
 		msg.type = isGroupchat ? MessageChannel : MessageChat;
-		msg.sender = isGroupchat ? msg.from : msg.from?.asBare();
+		msg.senderId = (isGroupchat ? msg.from : msg.from?.asBare())?.asString();
 		final localJidBare = localJid.asBare();
 		final domain = localJid.domain;
 		final to = stanza.attr.get("to");
@@ -126,7 +126,7 @@ class Message {
 					replyTo.clear();
 				} else if (jid == null) {
 					trace("No support for addressing to non-jid", address);
-					return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza));
+					return new Message(msg.chatId(), msg.senderId, msg.threadId, 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
@@ -137,9 +137,9 @@ class Message {
 					}
 					replyTo[JID.parse(jid).asString()] = true;
 				} else if (address.attr.get("type") == "ofrom") {
-					if (JID.parse(jid).domain == msg.sender?.domain) {
+					if (JID.parse(jid).domain == msg.from?.domain) {
 						// TODO: check that domain supports extended addressing
-						msg.sender = JID.parse(jid).asBare();
+						msg.senderId = JID.parse(jid).asBare().asString();
 					}
 				}
 			}
@@ -153,24 +153,28 @@ class Message {
 		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 new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza));
+			return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza));
 		}
 
+		if (addContext != null) msg = addContext(msg, stanza);
+		final timestamp = msg.timestamp ?? Date.format(std.Date.now());
+		msg.timestamp = timestamp;
+
 		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 new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
+				return new Message(msg.chatId(), msg.senderId, msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
 					stanza.attr.get("id") ?? ID.long(),
 					isGroupchat ? reactionId : null,
 					isGroupchat ? msg.chatId() : null,
 					isGroupchat ? null : reactionId,
 					msg.chatId(),
-					msg.senderId(),
+					msg.senderId,
 					timestamp,
-					reactions.map(text -> new Reaction(msg.senderId(), timestamp, text, msg.localId)),
+					reactions.map(text -> new Reaction(msg.senderId, timestamp, text, msg.localId)),
 					EmojiReactions
 				)));
 			}
@@ -193,10 +197,10 @@ class Message {
 			msg.payloads.push(jmi);
 			if (msg.text == null) msg.text = "call " + jmi.name;
 			if (jmi.name != "propose") {
-				msg.versions = [msg.clone()];
+				msg.versions = [msg.build()];
 			}
 			// The session id is what really identifies us
-			Reflect.setField(msg, "localId", jmi.attr.get("id"));
+			msg.localId = jmi.attr.get("id");
 		}
 
 		final retract = stanza.getChild("replace", "urn:xmpp:message-retract:1");
@@ -209,7 +213,7 @@ class Message {
 			// TODO: occupant id as well / instead of by?
 			return new Message(
 				msg.chatId(),
-				msg.senderId(),
+				msg.senderId,
 				msg.threadId,
 				ModerateMessageStanza(new ModerationAction(msg.chatId(), moderateServerId, timestamp, by, reason))
 			);
@@ -218,7 +222,7 @@ class Message {
 		final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0");
 		final replaceId  = replace?.attr?.get("id");
 
-		if (msg.text == null && msg.attachments.length < 1 && replaceId == null) return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza));
+		if (msg.text == null && msg.attachments.length < 1 && replaceId == null) return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza));
 
 		for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) {
 			msg.payloads.push(fallback);
@@ -241,15 +245,15 @@ class Message {
 
 			final text = msg.text;
 			if (text != null && EmojiUtil.isOnlyEmoji(text.trim())) {
-				return new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
+				return new Message(msg.chatId(), msg.senderId, msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
 					stanza.attr.get("id") ?? ID.long(),
 					isGroupchat ? replyToID : null,
 					isGroupchat ? msg.chatId() : null,
 					isGroupchat ? null : replyToID,
 					msg.chatId(),
-					msg.senderId(),
+					msg.senderId,
 					timestamp,
-					[new Reaction(msg.senderId(), timestamp, text.trim(), msg.localId)],
+					[new Reaction(msg.senderId, timestamp, text.trim(), msg.localId)],
 					AppendReactions
 				)));
 			}
@@ -261,15 +265,15 @@ class Message {
 					if (els.length == 1 && els[0].name == "img") {
 						final hash = Hash.fromUri(els[0].attr.get("src") ?? "");
 						if (hash != null) {
-							return new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
+							return new Message(msg.chatId(), msg.senderId, msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
 								stanza.attr.get("id") ?? ID.long(),
 								isGroupchat ? replyToID : null,
 								isGroupchat ? msg.chatId() : null,
 								isGroupchat ? null : replyToID,
 								msg.chatId(),
-								msg.senderId(),
+								msg.senderId,
 								timestamp,
-								[new CustomEmojiReaction(msg.senderId(), timestamp, els[0].attr.get("alt") ?? "", hash.serializeUri(), msg.localId)],
+								[new CustomEmojiReaction(msg.senderId, timestamp, els[0].attr.get("alt") ?? "", hash.serializeUri(), msg.localId)],
 								AppendReactions
 							)));
 						}
@@ -279,24 +283,25 @@ class Message {
 
 			if (replyToID != null) {
 				// Reply stub
-				final replyToMessage = new ChatMessage();
+				final replyToMessage = new ChatMessageBuilder();
+				replyToMessage.to = replyToJid == msg.senderId ? msg.to : msg.from;
 				replyToMessage.from = replyToJid == null ? null : JID.parse(replyToJid);
-				replyToMessage.sender = replyToMessage.from;
+				replyToMessage.senderId = replyToMessage.from?.asString();
 				replyToMessage.replyId = replyToID;
-				if (isGroupchat) {
+				if (msg.serverIdBy != null && msg.serverIdBy != localJid.asBare().asString()) {
 					replyToMessage.serverId = replyToID;
 				} else {
 					replyToMessage.localId = replyToID;
 				}
-				msg.replyToMessage = replyToMessage;
+				msg.replyToMessage = replyToMessage.build();
 			}
 		}
 
 		if (replaceId != null) {
-			msg.versions = [msg.clone()];
-			Reflect.setField(msg, "localId", replaceId);
+			msg.versions = [msg.build()];
+			msg.localId = replaceId;
 		}
 
-		return new Message(msg.chatId(), msg.senderId(), msg.threadId, ChatMessageStanza(msg));
+		return new Message(msg.chatId(), msg.senderId, msg.threadId, ChatMessageStanza(msg.build()));
 	}
 }
diff --git a/snikket/MessageSync.hx b/snikket/MessageSync.hx
index cbca577..0c9bb79 100644
--- a/snikket/MessageSync.hx
+++ b/snikket/MessageSync.hx
@@ -24,6 +24,7 @@ class MessageSync {
 	private var filter:MessageFilter;
 	private var serviceJID:String;
 	private var handler:MessageListHandler;
+	private var contextHandler:(ChatMessageBuilder, Stanza)->ChatMessageBuilder = (b,_)->b;
 	private var errorHandler:(Stanza)->Void;
 	public var lastPage(default, null):ResultSetPageResult;
 	public var progress(default, null): Int = 0;
@@ -82,14 +83,12 @@ class MessageSync {
 				jmi.set(jmiChildren[0].attr.get("id"), originalMessage);
 			}
 
-			final msg = Message.fromStanza(originalMessage, client.jid, timestamp).parsed;
-
-			switch (msg) {
-				case ChatMessageStanza(chatMessage):
-					chatMessage.serverId = result.attr.get("id");
-					chatMessage.serverIdBy = serviceJID;
-				default:
-			}
+			final msg = Message.fromStanza(originalMessage, client.jid, (builder, stanza) -> {
+				builder.serverId = result.attr.get("id");
+				builder.serverIdBy = serviceJID;
+				if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp;
+				return contextHandler(builder, stanza);
+			}).parsed;
 
 			messages.push(msg);
 
@@ -120,6 +119,10 @@ class MessageSync {
 		return !complete;
 	}
 
+	public function addContext(handler: (ChatMessageBuilder, Stanza)->ChatMessageBuilder) {
+		this.contextHandler = handler;
+	}
+
 	public function onMessages(handler:MessageListHandler):Void {
 		this.handler = handler;
 	}
diff --git a/snikket/Stanza.hx b/snikket/Stanza.hx
index dcce07d..27b345e 100644
--- a/snikket/Stanza.hx
+++ b/snikket/Stanza.hx
@@ -307,7 +307,7 @@ class Stanza implements NodeInterface {
 		};
 	}
 
-	public function findText(path:String):String {
+	public function findText(path:String):Null<String> {
 		var result = find(path);
 		if(result == null) {
 			return null;
diff --git a/snikket/jingle/Session.hx b/snikket/jingle/Session.hx
index 7ce4fcb..417b380 100644
--- a/snikket/jingle/Session.hx
+++ b/snikket/jingle/Session.hx
@@ -38,7 +38,7 @@ interface Session {
 }
 
 private function mkCallMessage(to: JID, from: JID, event: Stanza) {
-	final m = new ChatMessage();
+	final m = new ChatMessageBuilder();
 	m.type = MessageCall;
 	m.to = to;
 	m.recipients = [to.asBare()];
@@ -51,10 +51,10 @@ private function mkCallMessage(to: JID, from: JID, event: Stanza) {
 	m.payloads.push(event);
 	m.localId = ID.long();
 	if (event.name != "propose") {
-		m.versions = [m.clone()];
+		m.versions = [m.build()];
 	}
-	Reflect.setField(m, "localId", event.attr.get("id"));
-	return m;
+	m.localId = event.attr.get("id");
+	return m.build();
 }
 
 class IncomingProposedSession implements Session {
diff --git a/snikket/persistence/IDB.js b/snikket/persistence/IDB.js
index f88c938..4f30c56 100644
--- a/snikket/persistence/IDB.js
+++ b/snikket/persistence/IDB.js
@@ -89,7 +89,7 @@ export default (dbname, media, tokenize, stemmer) => {
 		const tx = db.transaction(["messages"], "readonly");
 		const store = tx.objectStore("messages");
 
-		const message = new snikket.ChatMessage();
+		const message = new snikket.ChatMessageBuilder();
 		message.localId = value.localId ? value.localId : null;
 		message.serverId = value.serverId ? value.serverId : null;
 		message.serverIdBy = value.serverIdBy ? value.serverIdBy : null;
@@ -101,6 +101,7 @@ export default (dbname, media, tokenize, stemmer) => {
 		message.to = value.to && snikket.JID.parse(value.to);
 		message.from = value.from && snikket.JID.parse(value.from);
 		message.sender = value.sender && snikket.JID.parse(value.sender);
+		message.senderId = value.senderId;
 		message.recipients = value.recipients.map((r) => snikket.JID.parse(r));
 		message.replyTo = value.replyTo.map((r) => snikket.JID.parse(r));
 		message.threadId = value.threadId;
@@ -110,7 +111,8 @@ export default (dbname, media, tokenize, stemmer) => {
 		message.lang = value.lang;
 		message.type = value.type || (value.isGroupchat || value.groupchat ? enums.MessageType.Channel : enums.MessageType.Chat);
 		message.payloads = (value.payloads || []).map(snikket.Stanza.parse);
-		return message;
+		message.stanza = value.stanza && snikket.Stanza.parse(value.stanza);
+		return message.build();
 	}
 
 	async function hydrateMessage(value) {
@@ -137,13 +139,14 @@ export default (dbname, media, tokenize, stemmer) => {
 			chatId: message.chatId(),
 			to: message.to?.asString(),
 			from: message.from?.asString(),
-			sender: message.sender?.asString(),
+			senderId: message.senderId,
 			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 || ""],
 			versions: message.versions.map((m) => serializeMessage(account, m)),
 			payloads: message.payloads.map((p) => p.toString()),
+			stanza: message.stanza?.toString(),
 		}
 	}
 
@@ -165,7 +168,7 @@ export default (dbname, media, tokenize, stemmer) => {
 		// Calls can "edit" from multiple senders, but the original direction and sender holds
 		if (result.value.type === enums.MessageType.MessageCall) {
 			head.direction = result.value.direction;
-			head.sender = result.value.sender;
+			head.senderId = result.value.senderId;
 			head.from = result.value.from;
 			head.to = result.value.to;
 			head.replyTo = result.value.replyTo;
@@ -373,7 +376,7 @@ export default (dbname, media, tokenize, stemmer) => {
 				const store = tx.objectStore("messages");
 				return Promise.all([
 					promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, message.localId || [], message.chatId()]))),
-					promisifyRequest(tx.objectStore("reactions").openCursor(IDBKeyRange.only([account, message.chatId(), message.senderId(), message.localId || ""])))
+					promisifyRequest(tx.objectStore("reactions").openCursor(IDBKeyRange.only([account, message.chatId(), message.senderId, message.localId || ""])))
 				]).then(([result, reactionResult]) => {
 					if (reactionResult?.value?.append && message.html().trim() == "") {
 						this.getMessage(account, message.chatId(), reactionResult.value.serverId, reactionResult.value.localId, (reactToMessage) => {
@@ -381,16 +384,16 @@ export default (dbname, media, tokenize, stemmer) => {
 							const reactions = [];
 							for (const [k, reacts] of reactToMessage?.reactions || []) {
 								for (const react of reacts) {
-									if (react.senderId === message.senderId() && !previouslyAppended.includes(k)) reactions.push(react);
+									if (react.senderId === message.senderId && !previouslyAppended.includes(k)) reactions.push(react);
 								}
 							}
-							this.storeReaction(account, new snikket.ReactionUpdate(message.localId, reactionResult.value.serverId, reactionResult.value.serverIdBy, reactionResult.value.localId, message.chatId(), message.senderId(), message.timestamp, reactions, enums.ReactionUpdateKind.CompleteReactions), callback);
+							this.storeReaction(account, new snikket.ReactionUpdate(message.localId, reactionResult.value.serverId, reactionResult.value.serverIdBy, reactionResult.value.localId, message.chatId(), message.senderId, message.timestamp, reactions, enums.ReactionUpdateKind.CompleteReactions), callback);
 						});
 						return true;
 					} else if (result?.value && !message.isIncoming() && result?.value.direction === enums.MessageDirection.MessageSent && message.versions.length < 1) {
 						// Duplicate, we trust our own sent ids
 						return promisifyRequest(result.delete());
-					} else if (result?.value && (result.value.sender == message.senderId() || result.value.type == enums.MessageType.MessageCall) && (message.versions.length > 0 || (result.value.versions || []).length > 0)) {
+					} else if (result?.value && (result.value.sender == message.senderId || result.value.type == enums.MessageType.MessageCall) && (message.versions.length > 0 || (result.value.versions || []).length > 0)) {
 						hydrateMessage(correctMessage(account, message, result)).then(callback);
 						return true;
 					}
diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx
index 307ba82..1f1b42e 100644
--- a/snikket/persistence/Sqlite.hx
+++ b/snikket/persistence/Sqlite.hx
@@ -48,9 +48,11 @@ class Sqlite implements Persistence implements KeyValueStore {
 					correction_id TEXT NOT NULL,
 					sync_point INTEGER NOT NULL,
 					chat_id TEXT NOT NULL,
+					sender_id TEXT NOT NULL,
 					created_at INTEGER NOT NULL,
 					status INTEGER NOT NULL,
 					direction INTEGER NOT NULL,
+					type INTEGER NOT NULL,
 					stanza TEXT NOT NULL,
 					PRIMARY KEY (account_id, mam_id, mam_by, stanza_id)
 				);
@@ -293,14 +295,15 @@ class Sqlite implements Persistence implements KeyValueStore {
 			Promise.resolve(null);
 		}).then(_ ->
 			db.exec(
-				"INSERT OR REPLACE INTO messages VALUES " + messages.map(_ -> "(?,?,?,?,?,?,?,CAST(unixepoch(?, 'subsec') * 1000 AS INTEGER),?,?,?)").join(","),
+				"INSERT OR REPLACE INTO messages VALUES " + messages.map(_ -> "(?,?,?,?,?,?,?,?,CAST(unixepoch(?, 'subsec') * 1000 AS INTEGER),?,?,?,?)").join(","),
 				messages.flatMap(m -> {
 					final correctable = m;
 					final message = m.versions.length == 1 ? m.versions[0] : m; // TODO: storing multiple versions at once? We never do that right now
 					([
 						accountId, message.serverId, message.serverIdBy,
-						message.localId, correctable.localId ?? correctable.serverId, correctable.syncPoint, correctable.chatId(),
-						message.timestamp, message.status, message.direction,
+						message.localId, correctable.localId ?? correctable.serverId, correctable.syncPoint,
+						correctable.chatId(), correctable.senderId,
+						message.timestamp, message.status, message.direction, message.type,
 						message.asStanza().toString()
 					] : Array<Dynamic>);
 				})
@@ -318,7 +321,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 	}
 
 	public function getMessage(accountId: String, chatId: String, serverId: Null<String>, localId: Null<String>, callback: (Null<ChatMessage>)->Void) {
-		var q = "SELECT stanza, direction, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, mam_id, mam_by FROM messages WHERE account_id=? AND chat_id=?";
+		var q = "SELECT stanza, direction, type, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sync_point FROM messages WHERE account_id=? AND chat_id=?";
 		final params = [accountId, chatId];
 		if (serverId != null) {
 			q += " AND mam_id=?";
@@ -328,9 +331,8 @@ class Sqlite implements Persistence implements KeyValueStore {
 			params.push(localId);
 		}
 		q += "LIMIT 1";
-		db.exec(q, params).then(result -> {
-			for (row in result) {
-				final message = hydrateMessage(accountId, row);
+		db.exec(q, params).then(result -> hydrateMessages(accountId, result)).then(messages -> {
+			for (message in messages) {
 				(if (message.replyToMessage != null) {
 					hydrateReplyTo(accountId, [message], [{ chatId: chatId, serverId: message.replyToMessage.serverId, localId: message.replyToMessage.localId }]);
 				} else {
@@ -349,9 +351,12 @@ class Sqlite implements Persistence implements KeyValueStore {
 			json_group_object(COALESCE(versions.mam_id, versions.stanza_id), strftime('%FT%H:%M:%fZ', versions.created_at / 1000.0, 'unixepoch')) AS version_times,
 			json_group_object(COALESCE(versions.mam_id, versions.stanza_id), versions.stanza) AS versions,
 			messages.direction,
+			messages.type,
 			strftime('%FT%H:%M:%fZ', messages.created_at / 1000.0, 'unixepoch') AS timestamp,
+			messages.sender_id,
 			messages.mam_id,
 			messages.mam_by,
+			messages.sync_point,
 			MAX(versions.created_at)
 			FROM messages INNER JOIN messages versions USING (correction_id) WHERE messages.stanza_id=correction_id AND messages.account_id=? AND messages.chat_id=?";
 		final params = [accountId, chatId];
@@ -364,10 +369,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		q += ", messages.ROWID";
 		if (op == "<" || op == "<=") q += " DESC";
 		q += " LIMIT 50";
-		return db.exec(q, params).then(result -> ({
-			hasNext: result.hasNext,
-			next: () -> hydrateMessage(accountId, result.next())
-		})).then(iter -> {
+		return db.exec(q, params).then(result -> hydrateMessages(accountId, result)).then(iter -> {
 			final arr = [];
 			final replyTos = [];
 			for (message in iter) {
@@ -425,7 +427,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		final params: Array<Dynamic> = [accountId]; // subq is first in final q, so subq params first
 
 		final subq = new StringBuf();
-		subq.add("SELECT chat_id, MAX(ROWID) AS row FROM messages WHERE account_id=?");
+		subq.add("SELECT chat_id, MAX(created_at) AS created_at FROM messages WHERE account_id=?");
 		subq.add(" AND chat_id IN (");
 		for (i => chat in chats) {
 			if (i != 0) subq.add(",");
@@ -446,7 +448,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		params.push(MessageSent);
 
 		final q = new StringBuf();
-		q.add("SELECT chat_id AS chatId, stanza, direction, mam_id, mam_by, CASE WHEN subq.row IS NULL THEN COUNT(*) ELSE COUNT(*) - 1 END AS unreadCount, strftime('%FT%H:%M:%fZ', MAX(messages.created_at) / 1000.0, 'unixepoch') AS timestamp FROM messages LEFT JOIN (");
+		q.add("SELECT chat_id AS chatId, stanza, direction, type, sender_id, mam_id, mam_by, sync_point, CASE WHEN subq.created_at IS NULL THEN COUNT(*) ELSE COUNT(*) - 1 END AS unreadCount, strftime('%FT%H:%M:%fZ', MAX(messages.created_at) / 1000.0, 'unixepoch') AS timestamp FROM messages LEFT JOIN (");
 		q.add(subq.toString());
 		q.add(") subq USING (chat_id) WHERE account_id=? AND chat_id IN (");
 		params.push(accountId);
@@ -455,17 +457,20 @@ class Sqlite implements Persistence implements KeyValueStore {
 			q.add("?");
 			params.push(chat.chatId);
 		}
-		q.add(") AND (subq.row IS NULL OR messages.ROWID >= subq.row) GROUP BY chat_id;");
+		q.add(") AND (subq.created_at IS NULL OR messages.created_at >= subq.created_at) GROUP BY chat_id;");
 		db.exec(q.toString(), params).then(result -> {
 			final details = [];
-			for (row in result) {
-				details.push({
-					unreadCount: row.unreadCount,
-					chatId: row.chatId,
-					message: hydrateMessage(accountId, row)
-				});
-			}
-			callback(details);
+			final rows: Array<Dynamic> = { iterator: () -> result }.array();
+			Promise.resolve(hydrateMessages(accountId, rows.iterator())).then(messages -> {
+				for (i => m in messages) {
+					details.push({
+						unreadCount: rows[i].unreadCount,
+						chatId: rows[i].chatId,
+						message: m
+					});
+				}
+				callback(details);
+			});
 		});
 	}
 
@@ -490,12 +495,11 @@ class Sqlite implements Persistence implements KeyValueStore {
 			[status, accountId, localId, MessageSent, MessageDeliveredToDevice]
 		).then(_ ->
 			db.exec(
-				"SELECT stanza, strftime('%FT%H:%M:%fZ') AS timestamp, mam_id, mam_by FROM messages WHERE account_id=? AND stanza_id=? AND direction=?",
+				"SELECT stanza, direction, type, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sync_point FROM messages WHERE account_id=? AND stanza_id=? AND direction=?",
 				[accountId, localId, MessageSent]
 			)
-		).then(result -> {
-			for (row in result) {
-				final message = hydrateMessage(accountId, row);
+		).then(result -> hydrateMessages(accountId, result)).then(messages -> {
+			for (message in messages) {
 				(if (message.replyToMessage != null) {
 					hydrateReplyTo(accountId, [message], [{ chatId: message.chatId(), serverId: message.replyToMessage.serverId, localId: message.replyToMessage.localId }]);
 				} else {
@@ -668,7 +672,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		return fetchReactions(accountId, messages.map(m -> ({ chatId: m.chatId(), serverId: m.serverId, serverIdBy: m.serverIdBy, localId: m.localId }))).then(result -> {
 			for (id => reactions in result) {
 				final m = messages.find(m -> ((m.serverId == null ? m.localId : m.serverId + "\n" + m.serverIdBy) + "\n" + m.chatId()) == id);
-				if (m != null) m.reactions = reactions;
+				if (m != null) m.set_reactions(reactions);
 			}
 			return messages;
 		});
@@ -732,7 +736,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		} else {
 			final params = [accountId];
 			final q = new StringBuf();
-			q.add("SELECT chat_id, stanza_id, stanza, direction, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, mam_id, mam_by FROM messages WHERE account_id=? AND (");
+			q.add("SELECT chat_id, stanza_id, stanza, direction, type, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sync_point FROM messages WHERE account_id=? AND (");
 			q.add(replyTos.map(parent ->
 				if (parent.serverId != null) {
 					params.push(parent.chatId);
@@ -752,7 +756,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 				for (message in messages) {
 					if (message.replyToMessage != null) {
 						final found: Dynamic = parents.find(p -> p.chat_id == message.chatId() && (message.replyToMessage.serverId == null || p.mam_id == message.replyToMessage.serverId) && (message.replyToMessage.localId == null || p.stanza_id == message.replyToMessage.localId));
-						if (found != null) message.replyToMessage = hydrateMessage(accountId, found);
+						if (found != null) message.set_replyToMessage(hydrateMessages(accountId, [found].iterator())[0]);
 					}
 				}
 			}
@@ -760,31 +764,36 @@ class Sqlite implements Persistence implements KeyValueStore {
 		});
 	}
 
-	private function hydrateMessage(accountId: String, row: { stanza: String, timestamp: String, direction: MessageDirection, mam_id: String, mam_by: String, ?stanza_id: String, ?versions: String, ?version_times: String }) {
-		// TODO
+	private function hydrateMessages(accountId: String, rows: Iterator<{ stanza: String, timestamp: String, direction: MessageDirection, type: MessageType, mam_id: String, mam_by: String, sync_point: Bool, sender_id: String, ?stanza_id: String, ?versions: String, ?version_times: String }>): Array<ChatMessage> {
 		// TODO: Calls can "edit" from multiple senders, but the original direction and sender holds
 		final accountJid = JID.parse(accountId);
-		final x = ChatMessage.fromStanza(Stanza.parse(row.stanza), accountJid);
-		x.timestamp = row.timestamp;
-		x.direction = row.direction;
-		x.serverId = row.mam_id;
-		x.serverIdBy = row.mam_by;
-		if (row.stanza_id != null) Reflect.setField(x, "localId", row.stanza_id);
-		if (row.versions != null) {
-			final versionTimes: DynamicAccess<String> = Json.parse(row.version_times);
-			final versions: DynamicAccess<String> =  Json.parse(row.versions);
-			if (versions.keys().length > 1) {
-				for (version in versions) {
-					final versionM = ChatMessage.fromStanza(Stanza.parse(version), accountJid);
-					final toPush = versionM == null || versionM.versions.length < 1 ? versionM : versionM.versions[0];
-					if (toPush != null) {
-						toPush.timestamp = versionTimes[toPush.serverId ?? toPush.localId];
-						x.versions.push(toPush);
+		return { iterator: () -> rows }.map(row -> ChatMessage.fromStanza(Stanza.parse(row.stanza), accountJid, (builder, _) -> {
+			builder.syncPoint = row.sync_point;
+			builder.timestamp = row.timestamp;
+			builder.direction = row.direction;
+			builder.type = row.type;
+			builder.senderId = row.sender_id;
+			builder.serverId = row.mam_id;
+			builder.serverIdBy = row.mam_by;
+			if (row.stanza_id != null) builder.localId = row.stanza_id;
+			if (row.versions != null) {
+				final versionTimes: DynamicAccess<String> = Json.parse(row.version_times);
+				final versions: DynamicAccess<String> =  Json.parse(row.versions);
+				if (versions.keys().length > 1) {
+					for (version in versions) {
+						final versionM = ChatMessage.fromStanza(Stanza.parse(version), accountJid, (toPushB, _) -> {
+							toPushB.timestamp = versionTimes[toPushB.serverId ?? toPushB.localId];
+							return toPushB;
+						});
+						final toPush = versionM == null || versionM.versions.length < 1 ? versionM : versionM.versions[0];
+						if (toPush != null) {
+							builder.versions.push(toPush);
+						}
 					}
+					builder.versions.sort((a, b) -> Reflect.compare(b.timestamp, a.timestamp));
 				}
-				x.versions.sort((a, b) -> Reflect.compare(b.timestamp, a.timestamp));
 			}
-		}
-		return x;
+			return builder;
+		}));
 	}
 }