| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-23 02:29:11 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-23 03:25:39 UTC |
| parent | 8434bbc953accd38eafc6e4f961fa6fc29e0f6ba |
| 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 }) => {