git » sdk » commit cd10390

Parse and store message, thread, and chat subjects

author Stephen Paul Weber
2026-04-23 02:29:11 UTC
committer Stephen Paul Weber
2026-04-23 03:25:39 UTC
parent 8434bbc953accd38eafc6e4f961fa6fc29e0f6ba

Parse and store message, thread, and chat subjects

Chat subject is the subject of the null thread

borogove/Chat.hx +29 -2
borogove/ChatMessage.hx +7 -0
borogove/ChatMessageBuilder.hx +17 -3
borogove/Client.hx +2 -0
borogove/Map.js.hx +4 -0
borogove/Message.hx +10 -1
borogove/persistence/IDB.js +2 -0
borogove/persistence/Sqlite.hx +22 -4
test/TestChatMessage.hx +19 -0
test/idb.spec.ts +20 -5

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 7dfff8f..940627d 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -29,6 +29,12 @@ using borogove.Util;
 import HaxeCBridge;
 #end
 
+#if js
+typedef StringMapNullableKey = Map<Null<String>, String>;
+#else
+typedef StringMapNullableKey = haxe.ds.ObjectMap<Null<String>, String>;
+#end
+
 enum abstract UiState(Int) {
 	var Pinned;
 	var Open; // or Unspecified
@@ -102,6 +108,8 @@ abstract class Chat {
 	private var readUpToId: Null<String>;
 	@:allow(borogove)
 	private var readUpToBy: Null<String>;
+	@:allow(borogove.persistence)
+	private final threads: StringMapNullableKey = new StringMapNullableKey();
 	private var isTyping = false;
 	private var typingThread: Null<String> = null;
 	private var typingTimer: haxe.Timer = null;
@@ -620,6 +628,11 @@ abstract class Chat {
 		return displayName;
 	}
 
+	@:allow(borogove)
+	private function setThreadSubject(threadId: String, subject: String) {
+		this.threads.set(threadId, subject);
+	}
+
 	@:allow(borogove)
 	private function setPresence(resource:String, presence:Presence) {
 		this.presence.set(resource, presence);
@@ -1420,6 +1433,16 @@ class Channel extends Chat {
 		return super.getDisplayName();
 	}
 
+	/**
+		Subject of this Channel
+	**/
+	public function subject() {
+		return threads.get(null) ?? "";
+	}
+
+	/**
+		Description of this Channel
+	**/
 	public function description() {
 		return (info()?.field("muc#roominfo_description")?.value ?? []).join("\n");
 	}
@@ -1428,7 +1451,6 @@ class Channel extends Chat {
 		return disco?.data?.find(d -> d.field("FORM_TYPE")?.value?.at(0) == "http://jabber.org/protocol/muc#roominfo");
 	}
 
-
 	override public function invite(chat: Chat, threadId: Null<String> = null) {
 		if (isPrivate()) {
 			client.sendStanza(
@@ -2171,6 +2193,7 @@ class SerializedChat {
 	public final extensions:String;
 	public final readUpToId:Null<String>;
 	public final readUpToBy:Null<String>;
+	public final threads: StringMapNullableKey = new StringMapNullableKey();
 	public final disco:Null<Caps>;
 	public final omemoContactDeviceIDs: Array<Int>;
 	public final klass:String;
@@ -2178,7 +2201,7 @@ class SerializedChat {
 	public final notifyMention: Bool;
 	public final notifyReply: Bool;
 
-	public function new(chatId: String, trusted: Bool, isBookmarked: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, disco: Null<Caps>, omemoContactDeviceIDs: Array<Int>, klass: String) {
+	public function new(chatId: String, trusted: Bool, isBookmarked: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, threads: StringMapNullableKey, disco: Null<Caps>, omemoContactDeviceIDs: Array<Int>, klass: String) {
 		this.chatId = chatId;
 		this.trusted = trusted;
 		this.isBookmarked = isBookmarked;
@@ -2193,6 +2216,7 @@ class SerializedChat {
 		this.notificationsFiltered = notificationsFiltered;
 		this.notifyMention = notifyMention;
 		this.notifyReply = notifyReply;
+		this.threads = threads;
 		this.disco = disco;
 		this.omemoContactDeviceIDs = omemoContactDeviceIDs;
 		this.klass = klass;
@@ -2227,6 +2251,9 @@ class SerializedChat {
 		for (resource => p in presence) {
 			chat.setPresence(resource, p);
 		}
+		for (threadId => subject in threads) {
+			chat.setThreadSubject(threadId, subject);
+		}
 		return chat;
 	}
 }
diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx
index 8564975..b62a84e 100644
--- a/borogove/ChatMessage.hx
+++ b/borogove/ChatMessage.hx
@@ -411,6 +411,13 @@ class ChatMessage {
 		return new Html(htmlBody(), sender);
 	}
 
+	/**
+		Subject if present
+	**/
+	public function subject(): Null<String> {
+		return payloads.find(el -> el.name == "subject")?.getText();
+	}
+
 	/**
 		The ID of the Chat this message is associated with
 	**/
diff --git a/borogove/ChatMessageBuilder.hx b/borogove/ChatMessageBuilder.hx
index bb17666..029e743 100644
--- a/borogove/ChatMessageBuilder.hx
+++ b/borogove/ChatMessageBuilder.hx
@@ -114,7 +114,7 @@ class ChatMessageBuilder {
 	/**
 		Direction of this message
 	**/
-	public var direction: MessageDirection = MessageReceived;
+	public var direction: MessageDirection = MessageSent;
 
 	/**
 		Status of this message
@@ -179,6 +179,7 @@ class ChatMessageBuilder {
 		?attachments: Array<ChatAttachment>,
 		?reactions: Map<String, Array<Reaction>>,
 		?text: Null<String>,
+		?subject: Null<String>,
 		?lang: Null<String>,
 		?direction: MessageDirection,
 		?status: MessageStatus,
@@ -209,6 +210,7 @@ class ChatMessageBuilder {
 		if (text != null) setBody(Html.text(text));
 		final html = params?.html;
 		if (html != null) setBody(html);
+		setSubject(params?.subject);
 	}
 	#end
 
@@ -297,6 +299,18 @@ class ChatMessageBuilder {
 		}
 	}
 
+	/**
+		Set subject of this message
+	**/
+	public function setSubject(subject: Null<String>) {
+		final subjectIdx = payloads.findIndex((p) -> p.name == "subject");
+		if (subjectIdx >= 0) payloads.splice(subjectIdx, 1);
+
+		if (subject == null) return;
+
+		payloads.push(new Stanza("subject").text(subject));
+	}
+
 	/**
 		The ID of the Chat this message is associated with
 
@@ -331,9 +345,9 @@ class ChatMessageBuilder {
 	**/
 	public function build() {
 		if (serverId == null && localId == null) throw "Cannot build a ChatMessage with no id";
-		final to = this.to;
+		final to = this.to ?? (isIncoming() ? new JID(null, "inbound.to.me.example.com") : null);
 		if (to == null) throw "Cannot build a ChatMessage with no to";
-		final from = this.from;
+		final from = this.from ?? (senderId == null ? null : JID.parse(senderId));
 		if (from == null) throw "Cannot build a ChatMessage with no from";
 		final sender = this.sender ?? from.asBare();
 		return new ChatMessage({
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 731d6b4..3eee7ac 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -522,6 +522,8 @@ class Client extends EventEmitter {
 				if (newChat != null) this.trigger("chats/update", [newChat]);
 			case MucInviteStanza(serverId, serverIdBy, reason, password):
 				mucInvite(message.chatId, getChat(message.chatId), message.senderId, message.threadId, serverId, serverIdBy, reason, password);
+			case SubjectStanza(subject):
+				getChat(message.chatId)?.setThreadSubject(message.threadId, subject);
 			default:
 				// ignore
 				trace("Ignoring non-chat message: " + stanza.toString());
diff --git a/borogove/Map.js.hx b/borogove/Map.js.hx
index 2d36b3e..07515a5 100644
--- a/borogove/Map.js.hx
+++ b/borogove/Map.js.hx
@@ -8,6 +8,10 @@ using Lambda;
 // Use ES6 maps instead of Haxe maps
 @:forward
 abstract Map<K,V>(NativeMap<K,V>) {
+	public inline function new() {
+		this = new NativeMap();
+	}
+
 	public inline function set(k:K, v:V):Void {
 		this.set(k, v);
 	}
diff --git a/borogove/Message.hx b/borogove/Message.hx
index ae918e4..2b53b8a 100644
--- a/borogove/Message.hx
+++ b/borogove/Message.hx
@@ -27,6 +27,7 @@ enum abstract MessageType(Int) {
 enum MessageStanza {
 	ErrorMessageStanza(localId: Null<String>, stanza: Stanza);
 	ChatMessageStanza(message: ChatMessage);
+	SubjectStanza(subject: String);
 	ModerateMessageStanza(action: ModerationAction);
 	ReactionUpdateStanza(update: ReactionUpdate);
 	MucInviteStanza(serverId: Null<String>, serverIdBy: Null<String>, reason: Null<String>, password: Null<String>);
@@ -70,6 +71,8 @@ class Message {
 		if (msg.text != null && (msg.lang == null || msg.lang == "")) {
 			msg.lang = stanza.getChild("body")?.attr.get("xml:lang");
 		}
+		final subject = stanza.getChild("subject");
+		if (subject != null) msg.payloads.push(subject);
 		msg.from = JID.parse(from);
 		final isGroupchat = stanza.attr.get("type") == "groupchat";
 		msg.type = isGroupchat ? MessageChannel : MessageChat;
@@ -256,7 +259,13 @@ 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), encryptionInfo);
+		if (msg.text == null && msg.attachments.length < 1 && replaceId == null) {
+			if (msg.payloads.length == 1 && msg.payloads[0].name == "subject") {
+				return new Message(msg.chatId(), msg.senderId, msg.threadId, SubjectStanza(msg.payloads[0].getText()), encryptionInfo);
+			}
+
+			return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza), encryptionInfo);
+		}
 
 		for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) {
 			msg.payloads.push(fallback);
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 3601a06..a92a7c1 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -452,6 +452,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 					readUpToId: chat.readUpToId,
 					readUpToBy: chat.readUpToBy,
 					notificationSettings: chat.notificationsFiltered() ? { mention: chat.notifyMention(), reply: chat.notifyReply() } : null,
+					threads: chat.threads,
 					disco: { ...chat.disco, data: chat.disco?.data?.map(d => d.toString()) },
 					omemoDevices: chat.omemoContactDeviceIDs,
 					class: chat instanceof borogove_DirectChat ? "DirectChat" : (chat instanceof borogove_Channel ? "Channel" : "Chat")
@@ -484,6 +485,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 				r.notificationSettings === undefined ? null : r.notificationSettings != null,
 				r.notificationSettings?.mention,
 				r.notificationSettings?.reply,
+				r.threads || new Map(),
 				r.disco ? new borogove_Caps(
 					r.disco.node,
 					(r.disco.identities || []).map((identity) => new borogove_Identity(identity.category, identity.type, identity.name)),
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 520f524..4f76925 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -221,6 +221,12 @@ class Sqlite implements Persistence implements KeyValueStore {
 						);
 					}
 					return Promise.resolve(null);
+				}).then(_ -> {
+					if (version < 9) {
+						return exec(["ALTER TABLE chats ADD COLUMN meta BLOB NOT NULL DEFAULT jsonb('{}')",
+						"PRAGMA user_version = 9"]);
+					}
+					return Promise.resolve(null);
 				});
 			});
 		});
@@ -287,7 +293,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			for (_ in storeChatBuffer) {
 				if (!first) q.add(",");
 				first = false;
-				q.add("(?,?,?,?,?,?,?,?,?,?,?,jsonb(?),?,?,?,?,?)");
+				q.add("(?,?,?,?,?,?,?,?,?,?,?,jsonb(?),?,?,?,?,?,jsonb(?))");
 			}
 			db.exec(
 				q.toString(),
@@ -301,7 +307,13 @@ class Sqlite implements Persistence implements KeyValueStore {
 						channel?.disco?.verRaw().hash, Json.stringify(mapPresence(chat)),
 						Type.getClassName(Type.getClass(chat)).split(".").pop(),
 						chat.notificationsFiltered(), chat.notifyMention(), chat.notifyReply(),
-						chat.isBookmarked
+						chat.isBookmarked, Json.stringify({
+							threads: {
+								final t: DynamicAccess<String> = {};
+								for (id => s in chat.threads) t.set(id ?? "", s);
+								t;
+							}
+						})
 					];
 					return row;
 				})
@@ -314,7 +326,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 	@HaxeCBridge.noemit
 	public function getChats(accountId: String): Promise<Array<SerializedChat>> {
 		return db.exec(
-			"SELECT chat_id, trusted, bookmarked, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, notifications_filtered, notify_mention, notify_reply, json(caps) AS caps, caps_ver, json(presence) AS presence, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?",
+			"SELECT chat_id, trusted, bookmarked, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, notifications_filtered, notify_mention, notify_reply, json(caps) AS caps, caps_ver, json(presence) AS presence, json(meta) AS meta, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?",
 			[accountId]
 		).then(result -> {
 			final chats: Array<SerializedChat> = [];
@@ -335,8 +347,14 @@ class Sqlite implements Persistence implements KeyValueStore {
 					}
 				}
 
+				final metaJson: { ?threads: Null<DynamicAccess<String>> } = Json.parse(row.meta);
+				final threadsMap: StringMapNullableKey = new StringMapNullableKey();
+				for (thread => subject in metaJson.threads ?? {}) {
+					threadsMap.set(thread == "" ? null : thread, subject);
+				}
+
 				// FIXME: Empty OMEMO contact device ids hardcoded in next line
-				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, row.capsObj, [], Reflect.field(row, "class")));
+				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, [], Reflect.field(row, "class")));
 			}
 			return chats;
 		});
diff --git a/test/TestChatMessage.hx b/test/TestChatMessage.hx
index d3feba9..620235a 100644
--- a/test/TestChatMessage.hx
+++ b/test/TestChatMessage.hx
@@ -117,4 +117,23 @@ class TestChatMessage extends utest.Test {
 				Assert.fail("Expected ChatMessageStanza");
 		}
 	}
+
+	public function testSubjectOnlyMessage() {
+		final stanza = new Stanza("message");
+		stanza.attr.set("id", "test-id-1");
+		stanza.attr.set("from", "tea@example.com/hatter");
+		stanza.attr.set("to", "bob@example.com");
+		stanza.attr.set("type", "groupchat");
+		stanza.addChild(new Stanza("thread").text("thread-1"));
+		stanza.addChild(new Stanza("subject").text("Tea Time"));
+
+		final msg = Message.fromStanza(stanza, JID.parse("bob@example.com"));
+		Assert.equals("thread-1", msg.threadId);
+		switch (msg.parsed) {
+			case SubjectStanza(subject):
+				Assert.equals("Tea Time", subject);
+			default:
+				Assert.fail("Expected SubjectStanza");
+		}
+	}
 }
diff --git a/test/idb.spec.ts b/test/idb.spec.ts
index 6edd3de..807f022 100644
--- a/test/idb.spec.ts
+++ b/test/idb.spec.ts
@@ -506,16 +506,31 @@ test("storeChats and getChats", async ({ page }) => {
 		chat.displayName = "The Mad Hatter";
 		chat.trusted = true;
 		chat.presence = new Map();
+		chat.threads = new Map([
+			[null, "Tea Time"],
+			["thread-1", "Introductions"],
+		]);
 
 		await persistence.storeChats("alice@example.com", [chat]);
-		return await persistence.getChats("alice@example.com");
+		const chats = await persistence.getChats("alice@example.com");
+		return {
+			length: chats.length,
+			chatId: chats[0]?.chatId,
+			displayName: chats[0]?.displayName,
+			trusted: chats[0]?.trusted,
+			klass: chats[0]?.klass,
+			channelSubject: chats[0]?.threads?.get(null),
+			threadSubject: chats[0]?.threads?.get("thread-1"),
+		};
 	}, code);
 
 	expect(result.length).toBe(1);
-	expect(result[0].chatId).toBe("hatter@example.com");
-	expect(result[0].displayName).toBe("The Mad Hatter");
-	expect(result[0].trusted).toBe(true);
-	expect(result[0].klass).toBe("DirectChat");
+	expect(result.chatId).toBe("hatter@example.com");
+	expect(result.displayName).toBe("The Mad Hatter");
+	expect(result.trusted).toBe(true);
+	expect(result.klass).toBe("DirectChat");
+	expect(result.channelSubject).toBe("Tea Time");
+	expect(result.threadSubject).toBe("Introductions");
 });
 
 test("getMessage by serverId and localId", async ({ page }) => {