git » sdk » commit 003768f

Store call history in with message history

author Stephen Paul Weber
2024-10-22 19:29:56 UTC
committer Stephen Paul Weber
2024-10-22 19:29:56 UTC
parent 5089e9b3730356dc349eaeab839f9b6c125966fd

Store call history in with message history

This design allows for a kind of message in chats that represents a
call.  ChatMessage#type == MessageCall

This type of message can be edited by either side, and the top level
localId is set to the sessionid.  This means that the whole history of
call status changes ends up in ChatMessage#versions

A helper was added to Client to allow easily storing a message that
way (without direct access to persistence which you need to know the
accountId for anyway), we should probably use that more places now.

Some events are stored even though we don't send them. Namely ringing
and retract. We may decide to send them in some cases eventually, and
they're useful for the local data model so we store as if we sent them already.

Makefile +2 -0
browserjs.hxml +5 -4
nodejs.hxml +6 -5
npm/index.ts +1 -0
snikket/Chat.hx +13 -2
snikket/ChatMessage.hx +50 -10
snikket/Client.hx +35 -30
snikket/Message.hx +31 -11
snikket/jingle/Session.hx +77 -17
snikket/persistence/browser.js +14 -4
snikket/streams/XmppJsStream.hx +1 -1

diff --git a/Makefile b/Makefile
index d24f144..12f7501 100644
--- a/Makefile
+++ b/Makefile
@@ -13,6 +13,7 @@ npm/snikket-browser.js:
 	sed -i 's/snikket\.UiState/enums.UiState/g' npm/snikket-browser.d.ts
 	sed -i 's/snikket\.MessageStatus/enums.MessageStatus/g' npm/snikket-browser.d.ts
 	sed -i 's/snikket\.MessageDirection/enums.MessageDirection/g' npm/snikket-browser.d.ts
+	sed -i 's/snikket\.MessageType/enums.MessageType/g' npm/snikket.d.ts
 	sed -i 's/snikket\.UserState/enums.UserState/g' npm/snikket-browser.d.ts
 	sed -i 's/_Push.Push_Fields_/Push/g' npm/snikket-browser.d.ts
 	sed -i '1ivar exports = {};' npm/snikket-browser.js
@@ -24,6 +25,7 @@ npm/snikket.js:
 	sed -i 's/snikket\.UiState/enums.UiState/g' npm/snikket.d.ts
 	sed -i 's/snikket\.MessageStatus/enums.MessageStatus/g' npm/snikket.d.ts
 	sed -i 's/snikket\.MessageDirection/enums.MessageDirection/g' npm/snikket.d.ts
+	sed -i 's/snikket\.MessageType/enums.MessageType/g' npm/snikket.d.ts
 	sed -i 's/snikket\.UserState/enums.UserState/g' npm/snikket.d.ts
 	sed -i 's/_Push.Push_Fields_/Push/g' npm/snikket.d.ts
 	sed -i '1iimport { createRequire } from "module";' npm/snikket.js
diff --git a/browserjs.hxml b/browserjs.hxml
index 829cf6d..cdfab9f 100644
--- a/browserjs.hxml
+++ b/browserjs.hxml
@@ -1,11 +1,12 @@
+--library HtmlParser
+--library datetime
 --library haxe-strings
---library hxtsdgen
 --library hsluv
---library tink_http
+--library hxtsdgen
+--library jsImport
 --library sha
 --library thenshim
---library HtmlParser
---library jsImport
+--library tink_http
 
 snikket.Client
 snikket.Push
diff --git a/nodejs.hxml b/nodejs.hxml
index c00a82d..6223ee5 100644
--- a/nodejs.hxml
+++ b/nodejs.hxml
@@ -1,12 +1,13 @@
+--library HtmlParser
+--library datetime
 --library haxe-strings
---library hxtsdgen
 --library hsluv
---library tink_http
---library sha
---library thenshim
---library HtmlParser
 --library hxnodejs
+--library hxtsdgen
 --library jsImport
+--library sha
+--library thenshim
+--library tink_http
 
 snikket.Client
 snikket.Push
diff --git a/npm/index.ts b/npm/index.ts
index 938e6aa..fd63ff2 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -24,6 +24,7 @@ export import jingle = snikket.jingle;
 export import UiState = enums.UiState;
 export import MessageStatus = enums.MessageStatus;
 export import MessageDirection = enums.MessageDirection;
+export import MessageType = enums.MessageType;
 export import UserState = enums.UserState;
 
 export namespace persistence {
diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index 9eecdb6..40d37a0 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -7,6 +7,7 @@ import snikket.ChatMessage;
 import snikket.Color;
 import snikket.GenericStream;
 import snikket.ID;
+import snikket.Message;
 import snikket.MessageSync;
 import snikket.jingle.PeerConnection;
 import snikket.jingle.Session;
@@ -330,7 +331,16 @@ abstract class Chat {
 		A preview of the chat, such as the most recent message body
 	**/
 	public function preview() {
-		return lastMessage?.text ?? "";
+		if (lastMessage == null) return "";
+
+		return switch (lastMessage.type) {
+			case MessageCall:
+				lastMessage.isIncoming() ? "Incoming Call" : "Outgoing Call";
+			case MessageChannel:
+				getParticipantDetails(lastMessage.senderId()).displayName + ": " + lastMessage.text;
+			default:
+				lastMessage.text;
+		}
 	}
 
 	@:allow(snikket)
@@ -1046,6 +1056,7 @@ class Channel extends Chat {
 	@:allow(snikket)
 	private function prepareIncomingMessage(message:ChatMessage, 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()) {
 			message.recipients = message.replyTo;
@@ -1055,7 +1066,7 @@ class Channel extends Chat {
 	}
 
 	private function prepareOutgoingMessage(message:ChatMessage) {
-		message.isGroupchat = true;
+		message.type = MessageChannel;
 		message.timestamp = message.timestamp ?? Date.format(std.Date.now());
 		message.direction = MessageSent;
 		message.from = client.jid;
diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx
index 06a2aac..dd22b24 100644
--- a/snikket/ChatMessage.hx
+++ b/snikket/ChatMessage.hx
@@ -1,5 +1,6 @@
 package snikket;
 
+import datetime.DateTime;
 import haxe.crypto.Base64;
 import haxe.io.Bytes;
 import haxe.io.BytesData;
@@ -68,9 +69,17 @@ class ChatMessage {
 		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
 	**/
@@ -115,13 +124,6 @@ class ChatMessage {
 	**/
 	public var lang: Null<String> = null;
 
-	/**
-		Is this a Group Chat message?
-
-		If the message is in the context of a Channel but this is false,
-		then it is a private message
-	**/
-	public var isGroupchat: Bool = false; // Only really useful for distinguishing whispers
 	/**
 		Direction of this message
 	**/
@@ -178,12 +180,17 @@ class ChatMessage {
 	**/
 	public function reply() {
 		final m = new ChatMessage();
-		m.isGroupchat = isGroupchat;
+		m.type = type;
 		m.threadId = threadId ?? ID.long();
 		m.replyToMessage = this;
 		return m;
 	}
 
+	public function getReplyId() {
+		if (replyId != null) return replyId;
+		return type == MessageChannel || type == MessageChannelPrivate ? serverId : localId;
+	}
+
 	private function set_localId(localId:Null<String>) {
 		if(this.localId != null) {
 			throw new Exception("Message already has a localId set");
@@ -350,10 +357,43 @@ class ChatMessage {
 		return threadId == null ? null : Identicon.svg(threadId);
 	}
 
+	/**
+		The last status of the call if this message is related to a call
+	**/
+	public function callStatus() {
+		return payloads.find((el) -> el.attr.get("xmlns") == "urn:xmpp:jingle-message:0")?.name;
+	}
+
+	/**
+		The duration of the call if this message is related to a call
+	**/
+	public function callDuration(): Null<String> {
+		if (versions.length < 2) return null;
+		final startedStr = versions[versions.length - 1].timestamp;
+
+		return switch (callStatus()) {
+		case "finish":
+			final endedStr = versions[0].timestamp;
+			if (startedStr == null || endedStr == null) return null;
+			final started = DateTime.fromString(startedStr);
+			final ended = DateTime.fromString(endedStr);
+			final duration = ended - started;
+			duration.format("%I:%S");
+		case "proceed":
+			if (startedStr == null) return null;
+			final started = DateTime.fromString(startedStr);
+			final ended = DateTime.now(); // ongoing
+			final duration = ended - started;
+			duration.format("%I:%S");
+		default:
+			null;
+		}
+	}
+
 	@:allow(snikket)
 	private function asStanza():Stanza {
 		var body = text;
-		var attrs: haxe.DynamicAccess<String> = { type: isGroupchat ? "groupchat" : "chat" };
+		var attrs: haxe.DynamicAccess<String> = { type: type == MessageChannel ? "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);
@@ -386,7 +426,7 @@ class ChatMessage {
 			}
 			final reaction = EmojiUtil.isEmoji(StringTools.trim(body)) ? StringTools.trim(body) : null;
 			body = quoteText + body;
-			final replyId = replyToM.isGroupchat ? replyToM.serverId : replyToM.localId;
+			final replyId = replyToM.getReplyId();
 			if (replyId != null) {
 				final codepoints = StringUtil.codepointArray(quoteText);
 				if (reaction != null) {
diff --git a/snikket/Client.hx b/snikket/Client.hx
index db58b17..1287b28 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -144,6 +144,36 @@ class Client extends EventEmitter {
 				}
 			}
 
+			final message = Message.fromStanza(stanza, this.jid);
+			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());
+					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);
+						}
+					}
+				case ReactionUpdateStanza(update):
+					persistence.storeReaction(accountId(), update, (stored) -> if (stored != null) notifyMessageHandlers(stored));
+				default:
+					// ignore
+			}
+
 			final jmiP = stanza.getChild("propose", "urn:xmpp:jingle-message:0");
 			if (jmiP != null && jmiP.attr.get("id") != null) {
 				final session = new IncomingProposedSession(this, from, jmiP.attr.get("id"));
@@ -199,36 +229,6 @@ class Client extends EventEmitter {
 				}
 			}
 
-			final message = Message.fromStanza(stanza, this.jid);
-			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());
-					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);
-						}
-					}
-				case ReactionUpdateStanza(update):
-					persistence.storeReaction(accountId(), update, (stored) -> if (stored != null) notifyMessageHandlers(stored));
-				default:
-					// ignore
-			}
-
 			if (stanza.attr.get("type") != "error") {
 				final chatState = stanza.getChild(null, "http://jabber.org/protocol/chatstates");
 				final userState = switch (chatState?.name) {
@@ -1067,6 +1067,11 @@ class Client extends EventEmitter {
 		chats.sort((a, b) -> -Reflect.compare(a.lastMessageTimestamp() ?? "0", b.lastMessageTimestamp() ?? "0"));
 	}
 
+	@:allow(snikket)
+	private function storeMessage(message: ChatMessage, ?callback: Null<(ChatMessage)->Void>) {
+		persistence.storeMessage(accountId(), message, callback ?? (_)->{});
+	}
+
 	@:allow(snikket)
 	private function sendQuery(query:GenericQuery) {
 		this.stream.sendIq(query.getQueryStanza(), query.handleResponse);
diff --git a/snikket/Message.hx b/snikket/Message.hx
index d9dfb51..8bbe47e 100644
--- a/snikket/Message.hx
+++ b/snikket/Message.hx
@@ -14,6 +14,13 @@ enum abstract MessageStatus(Int) {
 	var MessageFailedToSend; // There was an error sending this message
 }
 
+enum abstract MessageType(Int) {
+	var MessageChat;
+	var MessageCall;
+	var MessageChannel;
+	var MessageChannelPrivate;
+}
+
 enum MessageStanza {
 	ErrorMessageStanza(stanza: Stanza);
 	ChatMessageStanza(message: ChatMessage);
@@ -50,8 +57,9 @@ class Message {
 			msg.lang = stanza.getChild("body")?.attr.get("xml:lang");
 		}
 		msg.from = JID.parse(from);
-		msg.isGroupchat = stanza.attr.get("type") == "groupchat";
-		msg.sender = stanza.attr.get("type") == "groupchat" ? msg.from : msg.from?.asBare();
+		final isGroupchat = stanza.attr.get("type") == "groupchat";
+		msg.type = isGroupchat ? MessageChannel : MessageChat;
+		msg.sender = isGroupchat ? msg.from : msg.from?.asBare();
 		final localJidBare = localJid.asBare();
 		final domain = localJid.domain;
 		final to = stanza.attr.get("to");
@@ -85,6 +93,11 @@ class Message {
 				msg.serverIdBy = altServerId.attr.get("by");
 			}
 		}
+		if (msg.serverIdBy != null && msg.serverIdBy != localJid.domain) {
+			msg.replyId = msg.serverId;
+		} else if (msg.serverIdBy == localJid.domain) {
+			msg.replyId = msg.localId;
+		}
 		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
@@ -96,7 +109,7 @@ class Message {
 		}
 		final from = msg.from;
 		if (msg.direction == MessageReceived && from != null) {
-			replyTo[stanza.attr.get("type") == "groupchat" ? from.asBare().asString() : from.asString()] = true;
+			replyTo[isGroupchat ? from.asBare().asString() : from.asString()] = true;
 		} else if(msg.to != null) {
 			replyTo[msg.to.asString()] = true;
 		}
@@ -148,8 +161,8 @@ class Message {
 			if (reactionId != null) {
 				return new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
 					stanza.attr.get("id") ?? ID.long(),
-					stanza.attr.get("type") == "groupchat" ? reactionId : null,
-					stanza.attr.get("type") != "groupchat" ? reactionId : null,
+					isGroupchat ? reactionId : null,
+					isGroupchat ? null : reactionId,
 					msg.chatId(),
 					timestamp,
 					msg.senderId(),
@@ -169,6 +182,18 @@ class Message {
 			msg.attachSims(sims);
 		}
 
+		final jmi = stanza.getChild(null, "urn:xmpp:jingle-message:0");
+		if (jmi != null) {
+			msg.type = MessageCall;
+			msg.payloads.push(jmi);
+			if (msg.text == null) msg.text = "call " + jmi.name;
+			if (jmi.name != "propose") {
+				msg.versions = [msg.clone()];
+			}
+			// The session id is what really identifies us
+			Reflect.setField(msg, "localId", jmi.attr.get("id"));
+		}
+
 		if (msg.text == null && msg.attachments.length < 1) return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza));
 
 		for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) {
@@ -192,13 +217,8 @@ class Message {
 			if (replyToID != null) {
 				// Reply stub
 				final replyToMessage = new ChatMessage();
-				replyToMessage.isGroupchat = msg.isGroupchat;
 				replyToMessage.from = replyToJid == null ? null : JID.parse(replyToJid);
-				if (msg.isGroupchat) {
-					replyToMessage.serverId = replyToID;
-				} else {
-					replyToMessage.localId = replyToID;
-				}
+				replyToMessage.replyId = replyToID;
 				msg.replyToMessage = replyToMessage;
 			}
 		}
diff --git a/snikket/jingle/Session.hx b/snikket/jingle/Session.hx
index a6f6d63..fcbc4ac 100644
--- a/snikket/jingle/Session.hx
+++ b/snikket/jingle/Session.hx
@@ -1,5 +1,7 @@
 package snikket.jingle;
 
+import snikket.ChatMessage;
+import snikket.Message;
 import snikket.ID;
 import snikket.jingle.PeerConnection;
 import snikket.jingle.SessionDescription;
@@ -35,6 +37,26 @@ interface Session {
 	public function dtmf():Null<DTMFSender>;
 }
 
+private function mkCallMessage(to: JID, from: JID, event: Stanza) {
+	final m = new ChatMessage();
+	m.type = MessageCall;
+	m.to = to;
+	m.recipients = [to.asBare()];
+	m.from = from;
+	m.sender = m.from.asBare();
+	m.replyTo = [m.sender];
+	m.direction = MessageSent;
+	m.text = "call " + event.name;
+	m.timestamp = Date.format(std.Date.now());
+	m.payloads.push(event);
+	m.localId = ID.long();
+	if (event.name != "propose") {
+		m.versions = [m.clone()];
+	}
+	Reflect.setField(m, "localId", event.attr.get("id"));
+	return m;
+}
+
 class IncomingProposedSession implements Session {
 	public var sid (get, null): String;
 	private final client: Client;
@@ -50,12 +72,24 @@ class IncomingProposedSession implements Session {
 
 	public function ring() {
 		// XEP-0353 says to send <ringing/> but that leaks presence if not careful
+		// Store it for ourselves at least
+		final event = new Stanza("ringing", { xmlns: "urn:xmpp:jingle-message:0", id: sid });
+		final msg = mkCallMessage(from, client.jid, event);
+		client.storeMessage(msg, (stored) -> {
+			client.notifyMessageHandlers(stored);
+		});
 		client.trigger("call/ring", { chatId: from.asBare().asString(), session: this });
 	}
 
 	public function hangup() {
 		// XEP-0353 says to send <reject/> but that leaks presence if not careful
 		// It also tells all other devices to stop ringing, which you may or may not want
+		// Store it for ourselves at least
+		final event = new Stanza("reject", { xmlns: "urn:xmpp:jingle-message:0", id: sid });
+		final msg = mkCallMessage(from, client.jid, event);
+		client.storeMessage(msg, (stored) -> {
+			client.notifyMessageHandlers(stored);
+		});
 		client.getDirectChat(from.asBare().asString(), false).jingleSessions.remove(sid);
 	}
 
@@ -85,11 +119,16 @@ class IncomingProposedSession implements Session {
 		if (accepted) return;
 		accepted = true;
 		client.sendPresence(from.asString());
-		client.sendStanza(
-			new Stanza("message", { to: from.asString(), type: "chat" })
-				.tag("proceed", { xmlns: "urn:xmpp:jingle-message:0", id: sid }).up()
-				.tag("store", { xmlns: "urn:xmpp:hints" })
-		);
+		final event = new Stanza("proceed", { xmlns: "urn:xmpp:jingle-message:0", id: sid });
+		final msg = mkCallMessage(from, client.jid, event);
+		client.storeMessage(msg, (stored) -> {
+			client.notifyMessageHandlers(stored);
+			client.sendStanza(
+				new Stanza("message", { to: from.asString(), type: "chat", id: msg.versions[0].localId })
+					.addChild(event)
+					.tag("store", { xmlns: "urn:xmpp:hints" })
+			);
+		});
 	}
 
 	public function initiate(stanza: Stanza) {
@@ -139,17 +178,22 @@ class OutgoingProposedSession implements Session {
 	public function propose(audio: Bool, video: Bool) {
 		this.audio = audio;
 		this.video = video;
-		final stanza = new Stanza("message", { to: to.asString(), type: "chat" })
-			.tag("propose", { xmlns: "urn:xmpp:jingle-message:0", id: sid });
+		final event = new Stanza("propose", { xmlns: "urn:xmpp:jingle-message:0", id: sid });
 		if (audio) {
-			stanza.tag("description", { xmlns: "urn:xmpp:jingle:apps:rtp:1", media: "audio" }).up();
+			event.tag("description", { xmlns: "urn:xmpp:jingle:apps:rtp:1", media: "audio" }).up();
 		}
 		if (video) {
-			stanza.tag("description", { xmlns: "urn:xmpp:jingle:apps:rtp:1", media: "video" }).up();
+			event.tag("description", { xmlns: "urn:xmpp:jingle:apps:rtp:1", media: "video" }).up();
 		}
-		stanza.up().tag("store", { xmlns: "urn:xmpp:hints" });
-		client.sendStanza(stanza);
-		client.trigger("call/ringing", { chatId: to.asBare().asString() });
+		final msg = mkCallMessage(to, client.jid, event);
+		client.storeMessage(msg, (stored) -> {
+			final stanza = new Stanza("message", { to: to.asString(), type: "chat", id: msg.localId })
+				.addChild(event)
+				.tag("store", { xmlns: "urn:xmpp:hints" });
+			client.sendStanza(stanza);
+			client.notifyMessageHandlers(stored);
+			client.trigger("call/ringing", { chatId: to.asBare().asString() });
+		});
 	}
 
 	public function ring() {
@@ -157,11 +201,16 @@ class OutgoingProposedSession implements Session {
 	}
 
 	public function hangup() {
-		client.sendStanza(
-			new Stanza("message", { to: to.asString(), type: "chat" })
-				.tag("retract", { xmlns: "urn:xmpp:jingle-message:0", id: sid }).up()
-				.tag("store", { xmlns: "urn:xmpp:hints" })
-		);
+		final event = new Stanza("retract", { xmlns: "urn:xmpp:jingle-message:0", id: sid });
+		final msg = mkCallMessage(to, client.jid, event);
+		client.storeMessage(msg, (stored) -> {
+			client.sendStanza(
+				new Stanza("message", { to: to.asString(), type: "chat", id: msg.versions[0].localId })
+					.addChild(event)
+					.tag("store", { xmlns: "urn:xmpp:hints" })
+			);
+			client.notifyMessageHandlers(stored);
+		});
 		client.getDirectChat(to.asBare().asString(), false).jingleSessions.remove(sid);
 	}
 
@@ -315,6 +364,17 @@ class InitiatedSession implements Session {
 		}
 		pc = null;
 		client.trigger("call/retract", { chatId: counterpart.asBare().asString() });
+
+		final event = new Stanza("finish", { xmlns: "urn:xmpp:jingle-message:0", id: sid });
+		final msg = mkCallMessage(counterpart, client.jid, event);
+		client.storeMessage(msg, (stored) -> {
+			client.notifyMessageHandlers(stored);
+			client.sendStanza(
+				new Stanza("message", { to: counterpart.asString(), type: "chat", id: msg.versions[0].localId })
+					.addChild(event)
+					.tag("store", { xmlns: "urn:xmpp:hints" })
+			);
+		});
 	}
 
 	@:allow(snikket)
diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js
index e7ff5fb..9bc1e7b 100644
--- a/snikket/persistence/browser.js
+++ b/snikket/persistence/browser.js
@@ -70,6 +70,7 @@ const browser = (dbname, tokenize, stemmer) => {
 		message.localId = value.localId ? value.localId : null;
 		message.serverId = value.serverId ? value.serverId : null;
 		message.serverIdBy = value.serverIdBy ? value.serverIdBy : null;
+		message.replyId = value.replyId ? value.replyId : null;
 		message.syncPoint = !!value.syncPoint;
 		message.direction = value.direction;
 		message.status = value.status;
@@ -84,7 +85,7 @@ const browser = (dbname, tokenize, stemmer) => {
 		message.reactions = value.reactions;
 		message.text = value.text;
 		message.lang = value.lang;
-		message.isGroupchat = value.isGroupchat || value.groupchat;
+		message.type = value.type || (value.isGroupchat || value.groupchat ? enums.MessageType.Channel : enums.MessageType.Chat);
 		message.payloads = (value.payloads || []).map(snikket.Stanza.parse);
 		return message;
 	}
@@ -137,6 +138,15 @@ const browser = (dbname, tokenize, stemmer) => {
 		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
+		// 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.from = result.value.from;
+			head.to = result.value.to;
+			head.replyTo = result.value.replyTo;
+			head.recipients = result.value.recipients;
+		}
 		result.update(head);
 		return head;
 	}
@@ -324,7 +334,7 @@ const browser = (dbname, tokenize, stemmer) => {
 			if (message.serverId && !message.serverIdBy) throw "Cannot store a message with a server id and no by";
 			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)
+				message.replyToMessage && !message.replyToMessage.serverIdBy ? this.getMessage(account, message.chatId(), message.replyToMessage.getReplyId(), message.replyToMessage.getReplyId(), resolve) : resolve(message.replyToMessage)
 			).then((replyToMessage) => {
 				message.replyToMessage = replyToMessage;
 				const tx = db.transaction(["messages", "reactions"], "readwrite");
@@ -333,14 +343,14 @@ const browser = (dbname, tokenize, stemmer) => {
 					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() && (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;
 					}
 				}).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.isGroupchat ? message.serverId : message.localId) || ""], [account, message.chatId(), (message.isGroupchat ? message.serverId : message.localId) || "", []]), "prev");
+						const cursor = tx.objectStore("reactions").index("senders").openCursor(IDBKeyRange.bound([account, message.chatId(), message.getReplyId() || ""], [account, message.chatId(), message.getReplyId() || "", []]), "prev");
 						const reactions = new Map();
 						const reactionTimes = new Map();
 						cursor.onsuccess = (event) => {
diff --git a/snikket/streams/XmppJsStream.hx b/snikket/streams/XmppJsStream.hx
index 2e29ed7..27cf00b 100644
--- a/snikket/streams/XmppJsStream.hx
+++ b/snikket/streams/XmppJsStream.hx
@@ -331,7 +331,7 @@ class XmppJsStream extends GenericStream {
 	}
 
 	private function triggerSMupdate() {
-		if (!client.streamManagement.enabled || !client.streamManagement.allowResume) return;
+		if (client == null || !client.streamManagement.enabled || !client.streamManagement.allowResume) return;
 		this.trigger(
 			"sm/update",
 			{