git » sdk » commit 8052bd2

Update message status on error

author Stephen Paul Weber
2025-11-12 15:57:54 UTC
committer Stephen Paul Weber
2025-11-12 15:57:54 UTC
parent 1ee69268b46ac116a40519e767c2b5fc231dddb1

Update message status on error

borogove/ChatMessage.hx +9 -2
borogove/ChatMessageBuilder.hx +6 -0
borogove/Client.hx +11 -2
borogove/Message.hx +3 -3
borogove/Persistence.hx +1 -1
borogove/Register.hx +2 -12
borogove/Stanza.hx +9 -0
borogove/persistence/Dummy.hx +1 -1
borogove/persistence/IDB.js +6 -5
borogove/persistence/Sqlite.hx +16 -8

diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx
index 84881dc..7219581 100644
--- a/borogove/ChatMessage.hx
+++ b/borogove/ChatMessage.hx
@@ -190,7 +190,12 @@ class ChatMessage {
 	/**
 		Status of this message
 	**/
-	public var status: MessageStatus;
+	public final status: MessageStatus;
+
+	/**
+		Message to go along with the message status
+	**/
+	public final statusText: Null<String>;
 
 	/**
 		Array of past versions of this message, if it has been edited
@@ -205,7 +210,7 @@ class ChatMessage {
 		Information about the encryption used by the sender of
 		this message.
 	**/
-	public var encryption: Null<EncryptionInfo>;
+	public final encryption: Null<EncryptionInfo>;
 
 	@:allow(borogove)
 	private final stanza: Null<Stanza>;
@@ -232,6 +237,7 @@ class ChatMessage {
 		?lang: Null<String>,
 		?direction: MessageDirection,
 		?status: MessageStatus,
+		?statusText: String,
 		?versions: Array<ChatMessage>,
 		?payloads: Array<Stanza>,
 		?encryption: Null<EncryptionInfo>,
@@ -257,6 +263,7 @@ class ChatMessage {
 		this.lang = params.lang;
 		this.direction = params.direction ?? MessageSent;
 		this.status = params.status ?? MessagePending;
+		this.statusText = params.statusText;
 		this.versions = params.versions ?? [];
 		this.payloads = params.payloads ?? [];
 		this.encryption = params.encryption;
diff --git a/borogove/ChatMessageBuilder.hx b/borogove/ChatMessageBuilder.hx
index 4f5eba4..79bc7af 100644
--- a/borogove/ChatMessageBuilder.hx
+++ b/borogove/ChatMessageBuilder.hx
@@ -118,6 +118,11 @@ class ChatMessageBuilder {
 	**/
 	public var status: MessageStatus = MessagePending;
 
+	/**
+		Human readable text to go with the status
+	**/
+	public var statusText: Null<String> = null;
+
 	/**
 		Array of past versions of this message, if it has been edited
 	**/
@@ -346,6 +351,7 @@ class ChatMessageBuilder {
 			lang: lang,
 			direction: direction,
 			status: status,
+			statusText: statusText,
 			versions: versions,
 			payloads: payloads,
 			encryption: encryption,
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 1f20cda..51ce95c 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -155,7 +155,8 @@ class Client extends EventEmitter {
 			persistence.updateMessageStatus(
 				this.accountId(),
 				data.id,
-				MessageDeliveredToServer
+				MessageDeliveredToServer,
+				null
 			).then((m) -> notifyMessageHandlers(m, StatusEvent), _ -> null);
 			return EventHandled;
 		});
@@ -164,7 +165,8 @@ class Client extends EventEmitter {
 			persistence.updateMessageStatus(
 				this.accountId(),
 				data.id,
-				MessageFailedToSend
+				MessageFailedToSend,
+				null
 			).then((m) -> notifyMessageHandlers(m, StatusEvent), _ -> null);
 			return EventHandled;
 		});
@@ -460,6 +462,13 @@ class Client extends EventEmitter {
 				persistence.storeReaction(accountId(), update).then((stored) -> if (stored != null) notifyMessageHandlers(stored, ReactionEvent));
 			case ModerateMessageStanza(action):
 				moderateMessage(action).then((stored) -> if (stored != null) notifyMessageHandlers(stored, CorrectionEvent));
+			case ErrorMessageStanza(localId, stanza):
+				persistence.updateMessageStatus(
+					this.accountId(),
+					localId,
+					MessageFailedToSend,
+					stanza.getErrorText(),
+				).then((m) -> notifyMessageHandlers(m, StatusEvent), _ -> null);
 			default:
 				// ignore
 				trace("Ignoring non-chat message: " + stanza.toString());
diff --git a/borogove/Message.hx b/borogove/Message.hx
index ed2a31c..498b488 100644
--- a/borogove/Message.hx
+++ b/borogove/Message.hx
@@ -24,7 +24,7 @@ enum abstract MessageType(Int) {
 }
 
 enum MessageStanza {
-	ErrorMessageStanza(stanza: Stanza);
+	ErrorMessageStanza(localId: Null<String>, stanza: Stanza);
 	ChatMessageStanza(message: ChatMessage);
 	ModerateMessageStanza(action: ModerationAction);
 	ReactionUpdateStanza(update: ReactionUpdate);
@@ -50,12 +50,13 @@ class Message {
 	public static function fromStanza(stanza:Stanza, localJid:JID, ?addContext: (ChatMessageBuilder, Stanza)->ChatMessageBuilder, ?encryptionInfo:EncryptionInfo):Message {
 		final fromAttr = stanza.attr.get("from");
 		final from = fromAttr == null ? localJid.domain : fromAttr;
+		final localId = stanza.attr.get("id");
 		if(encryptionInfo==null) {
 			encryptionInfo = EncryptionInfo.fromStanza(stanza);
 		}
 
 		if (stanza.attr.get("type") == "error") {
-			return new Message(from, from, null, ErrorMessageStanza(stanza), encryptionInfo);
+			return new Message(from, from, null, ErrorMessageStanza(localId, stanza), encryptionInfo);
 		}
 
 		var msg = new ChatMessageBuilder();
@@ -89,7 +90,6 @@ class Message {
 			}
 		}
 
-		final localId = stanza.attr.get("id");
 		if (localId != null) msg.localId = localId;
 		var altServerId = null;
 		for (stanzaId in stanza.allTags("stanza-id", "urn:xmpp:sid:0")) {
diff --git a/borogove/Persistence.hx b/borogove/Persistence.hx
index 205da7c..e089d4f 100644
--- a/borogove/Persistence.hx
+++ b/borogove/Persistence.hx
@@ -23,7 +23,7 @@ interface Persistence {
 	public function storeReaction(accountId: String, update: ReactionUpdate): Promise<Null<ChatMessage>>;
 	public function storeMessages(accountId: String, message: Array<ChatMessage>): Promise<Array<ChatMessage>>;
 	public function updateMessage(accountId: String, message: ChatMessage):Void;
-	public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus): Promise<ChatMessage>;
+	public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus, statusText: Null<String>): Promise<ChatMessage>;
 	public function getMessage(accountId: String, chatId: String, serverId: Null<String>, localId: Null<String>): Promise<Null<ChatMessage>>;
 	public function getMessagesBefore(accountId: String, chatId: String, beforeId: Null<String>, beforeTime: Null<String>): Promise<Array<ChatMessage>>;
 	public function getMessagesAfter(accountId: String, chatId: String, afterId: Null<String>, afterTime: Null<String>): Promise<Array<ChatMessage>>;
diff --git a/borogove/Register.hx b/borogove/Register.hx
index c635d4d..68ce33c 100644
--- a/borogove/Register.hx
+++ b/borogove/Register.hx
@@ -71,7 +71,7 @@ class Register {
 	**/
 	public function getForm() {
 		return stream.register(domain, preAuth).then(reply -> {
-			final error = getError(reply);
+			final error = reply.getErrorText();
 			if (error != null) return Promise.reject(error);
 
 			final query = reply.getChild("query", "jabber:iq:register");
@@ -118,7 +118,7 @@ class Register {
 						.tag("query", { xmlns: "jabber:iq:register" })
 						.addChild(toSubmit),
 					(reply) -> {
-						final error = getError(reply);
+						final error = reply.getErrorText();
 						if (error != null) return reject(error);
 
 						// It is conventional for username@domain to be the registered JID
@@ -136,14 +136,4 @@ class Register {
 	public function disconnect() {
 		stream.disconnect();
 	}
-
-	private function getError(iq: Stanza) {
-		if (iq.attr.get("type") == "error") {
-			final error = iq.getChild("error");
-			final text = error.getChildText("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
-			return text ?? error.getFirstChild()?.name ?? "error";
-		}
-
-		return null;
-	}
 }
diff --git a/borogove/Stanza.hx b/borogove/Stanza.hx
index aa9c9c4..d7d043d 100644
--- a/borogove/Stanza.hx
+++ b/borogove/Stanza.hx
@@ -267,6 +267,15 @@ class Stanza {
 		return allTags()[0];
 	}
 
+	public function getErrorText() {
+		if (attr.get("type") == "error") {
+			final error = getError();
+			return error.text ?? error.condition ?? error.type;
+		}
+
+		return null;
+	}
+
 	public function getChild(?name:Null<String>, ?xmlns:Null<String>):Null<Stanza> {
 		var ourXmlns = this.attr.get("xmlns");
 		/*
diff --git a/borogove/persistence/Dummy.hx b/borogove/persistence/Dummy.hx
index 48d6f8f..7451752 100644
--- a/borogove/persistence/Dummy.hx
+++ b/borogove/persistence/Dummy.hx
@@ -82,7 +82,7 @@ class Dummy implements Persistence {
 	}
 
 	@HaxeCBridge.noemit
-	public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus): Promise<ChatMessage> {
+	public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus, statusText: Null<String>): Promise<ChatMessage> {
 		return Promise.reject("Dummy cannot updateMessageStatus");
 	}
 
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 99706f1..0354467 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -139,6 +139,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 		message.syncPoint = !!value.syncPoint;
 		message.direction = value.direction;
 		message.status = value.status;
+		message.statusText = value.statusText;
 		message.timestamp = value.timestamp && value.timestamp.toISOString();
 		message.from = value.from && borogove.JID.parse(value.from);
 		message.sender = value.sender && borogove.JID.parse(value.sender);
@@ -480,12 +481,12 @@ export default async (dbname, media, tokenize, stemmer) => {
 			store.put(serializeMessage(account, message));
 		},
 
-		updateMessageStatus: async function(account, localId, status) {
+		updateMessageStatus: async function(account, localId, status, statusText) {
 			const tx = db.transaction(["messages"], "readwrite");
 			const store = tx.objectStore("messages");
 			const result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.bound([account, localId], [account, localId, []])));
-			if (result?.value && result.value.direction === enums.MessageDirection.MessageSent && result.value.status !== enums.MessageStatus.MessageDeliveredToDevice) {
-				const newStatus = { ...result.value, status: status };
+			if (result?.value && result.value.direction === enums.MessageDirection.MessageSent && ![enums.MessageStatus.MessageDeliveredToDevice, enums.MessageStatus.MessageFailedToSend].includes(result.value.status)) {
+				const newStatus = { ...result.value, status, statusText };
 				result.update(newStatus);
 				return await hydrateMessage(newStatus);
 			}
@@ -501,8 +502,8 @@ export default async (dbname, media, tokenize, stemmer) => {
 				if (!cresult) break;
 
 				const value = cresult.value;
-				if (value?.versions?.[0]?.localId === localId && value?.direction === enums.MessageDirection.MessageSent && value?.status !== enums.MessageStatus.MessageDeliveredToDevice) {
-					const newStatus = { ...value, versions: [{ ...value.versions[0], status: status }, ...value.versions.slice(1)], status: status };
+				if (value?.versions?.[0]?.localId === localId && value?.direction === enums.MessageDirection.MessageSent && ![enums.MessageStatus.MessageDeliveredToDevice, enums.MessageStatus.MessageFailedToSend].includes(result.value.status)) {
+					const newStatus = { ...value, versions: [{ ...value.versions[0], status, statusText }, ...value.versions.slice(1)], status, statusText };
 					cresult.update(newStatus);
 					return await hydrateMessage(newStatus);
 				}
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 4d2005d..a9d6309 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -130,6 +130,12 @@ class Sqlite implements Persistence implements KeyValueStore {
 						"PRAGMA user_version = 2;"]);
 					}
 					return Promise.resolve(null);
+				}).then(_ -> {
+					if (version < 3) {
+						return exec(["ALTER TABLE messages ADD COLUMN status_text TEXT;",
+						"PRAGMA user_version = 3;"]);
+					}
+					return Promise.resolve(null);
 				});
 			});
 		});
@@ -355,7 +361,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		@returns Promise resolving to the message or null
 	**/
 	public function getMessage(accountId: String, chatId: String, serverId: Null<String>, localId: Null<String>): Promise<Null<ChatMessage>> {
-		var q = "SELECT stanza, direction, type, status, 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=?";
+		var q = "SELECT stanza, direction, type, status, status_text, 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=?";
@@ -385,6 +391,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			messages.direction,
 			messages.type,
 			messages.status,
+			messages.status_text,
 			strftime('%FT%H:%M:%fZ', messages.created_at / 1000.0, 'unixepoch') AS timestamp,
 			messages.sender_id,
 			messages.mam_id,
@@ -479,7 +486,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			params.push(MessageSent);
 
 			final q = new StringBuf();
-			q.add("SELECT chat_id AS chatId, stanza, direction, type, status, 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("SELECT chat_id AS chatId, stanza, direction, type, status, status_text, 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 (stanza_id IS NULL OR stanza_id='' OR stanza_id=correction_id) AND chat_id IN (");
 			params.push(accountId);
@@ -522,13 +529,13 @@ class Sqlite implements Persistence implements KeyValueStore {
 	}
 
 	@HaxeCBridge.noemit
-	public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus): Promise<ChatMessage> {
+	public function updateMessageStatus(accountId: String, localId: String, status: MessageStatus, statusText: Null<String>): Promise<ChatMessage> {
 		return db.exec(
-			"UPDATE messages SET status=? WHERE account_id=? AND stanza_id=? AND direction=? AND status <> ?",
-			[status, accountId, localId, MessageSent, MessageDeliveredToDevice]
+			"UPDATE messages SET status=?, status_text=? WHERE account_id=? AND stanza_id=? AND direction=? AND status <> ? AND status <> ?",
+			[status, statusText, accountId, localId, MessageSent, MessageDeliveredToDevice, MessageFailedToSend]
 		).then(_ ->
 			db.exec(
-				"SELECT stanza, direction, type, status, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, correction_id AS stanza_id, mam_id, mam_by, sync_point FROM messages WHERE account_id=? AND stanza_id=? AND direction=? LIMIT 1",
+				"SELECT stanza, direction, type, status, status_text, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, correction_id AS stanza_id, mam_id, mam_by, sync_point FROM messages WHERE account_id=? AND stanza_id=? AND direction=? LIMIT 1",
 				[accountId, localId, MessageSent]
 			)
 		).then(result ->
@@ -803,7 +810,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		} else {
 			final params = [accountId];
 			final q = new StringBuf();
-			q.add("SELECT chat_id, stanza_id, stanza, direction, type, status, 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("SELECT chat_id, stanza_id, stanza, direction, type, status, status_text, 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);
@@ -831,7 +838,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		});
 	}
 
-	private function hydrateMessages(accountId: String, rows: Iterator<{ stanza: String, timestamp: String, direction: MessageDirection, type: MessageType, status: MessageStatus, mam_id: String, mam_by: String, sync_point: Int, sender_id: String, ?stanza_id: String, ?versions: String, ?version_times: String }>): Array<ChatMessage> {
+	private function hydrateMessages(accountId: String, rows: Iterator<{ stanza: String, timestamp: String, direction: MessageDirection, type: MessageType, status: MessageStatus, status_text: Null<String>, mam_id: String, mam_by: String, sync_point: Int, 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);
 		return { iterator: () -> rows }.map(row -> ChatMessage.fromStanza(Stanza.parse(row.stanza), accountJid, (builder, _) -> {
@@ -839,6 +846,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			builder.timestamp = row.timestamp;
 			builder.type = row.type;
 			builder.status = row.status;
+			builder.statusText = row.status_text;
 			builder.senderId = row.sender_id;
 			builder.serverId = row.mam_id == "" ? null : row.mam_id;
 			builder.serverIdBy = row.mam_by == "" ? null : row.mam_by;