borogove

created pr with borogove/multi-dm.1 on 2026-11-09T19:47:55Z · by 9e834e0d
added borogove/multi-dm.2 on 2026-11-10T19:08:22Z · by 9e834e0d
1: 0ef2769 = 1: 41e89c8 Use to for chatId if not in recipients
2: 90e5a6a = 2: c323e3d clone should clone attributes
3: d86ea28 = 3: a1199ae Actually send to all recipients
4: 1c1061f ! 4: 94bf87d Never add self as counterpart
5: 9c8aa41 = 5: 32b9fad Extended addressing for sent messages specifies all recipients
6: 2da9124 = 6: ce4d9e1 Always add all recipients to outbound message
7: e7db006 = 7: 350343e Use a shared helper for adding extended addressing
8: 4530ece = 8: 40f662a Add startMultiChat to create multiDMs
9: 6810ba9 = 9: 8043c53 Never include ourselves in incoming chat ids
-: ------- > 10: 68e7246 Cannot MAM or bookmark a multiDM
cmds
checkout latest patchset:
ssh pr.pico.sh pull borogove:multi-dm | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull borogove:multi-dm [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh borogove:multi-dm
add comment to patch request:
ssh pr.pico.sh comment borogove:multi-dm "lgtm!"
+3 -3 borogove/Chat.hx #
......@@ -1108,9 +1108,9 @@ class DirectChat extends Chat {
11081108 private var _fullCounterparts: Array<String>;
11091109
11101110 @:allow(borogove)
1111- private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBookmarked = false, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null, omemoContactDeviceIDs: Array<Int> = null) {
1111+ private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, ?fullCounterparts:Array<String>, uiState = Open, isBookmarked = false, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null, omemoContactDeviceIDs: Array<Int> = null) {
11121112 super(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs);
1113- _fullCounterparts = counterparts();
1113+ _fullCounterparts = fullCounterparts ?? counterparts();
11141114 outbox.start();
11151115 }
11161116
......@@ -2856,7 +2856,7 @@ class SerializedChat {
28562856 }
28572857
28582858 final chat = if (klass == "DirectChat") {
2859- new DirectChat(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs);
2859+ new DirectChat(client, stream, persistence, chatId, null, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs);
28602860 } else if (klass == "Channel") {
28612861 final channel = new Channel(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy);
28622862 channel.membersForName = membersForName;
+43 -4 borogove/Client.hx #
......@@ -1561,6 +1561,46 @@ class Client extends EventEmitter {
15611561 return chat;
15621562 }
15631563
1564+ /**
1565+ Start multiDM chat from multiple search results
1566+
1567+ @param counterparts participants to include
1568+ @param target optional service-specific target when needed
1569+ @returns the chat that was started
1570+ **/
1571+ public function startMultiChat(counterparts: Array<AvailableChat>, ?target: String): DirectChat {
1572+ final participants: Array<String> = [];
1573+ for (counterpart in counterparts) {
1574+ if (counterpart.isChannel()) throw 'Cannot start multiDM with a channel: ${counterpart.chatId}';
1575+ final jid = JID.parse(counterpart.chatId);
1576+ if (!jid.isValid()) throw 'Invalid participant: ${counterpart.chatId}';
1577+ final id = jid.asBare().asString();
1578+ if (id == accountId()) continue;
1579+ if (!participants.contains(id)) participants.push(id);
1580+ }
1581+ if (participants.length < 2) throw "A multiDM needs at least two counterparts";
1582+
1583+ participants.sort(Reflect.compare);
1584+
1585+ if (target != null) {
1586+ final jid = JID.parse(target);
1587+ if (!jid.isValid() || !jid.isBare()) throw 'Invalid target: $target';
1588+ }
1589+
1590+ final chatId = target ?? participants.join("\n");
1591+
1592+ final existing = getChat(chatId);
1593+ if (existing != null) {
1594+ final chat = Std.downcast(existing, DirectChat);
1595+ if (chat == null) throw 'Chat $chatId already exists and is not a direct chat';
1596+ return chat;
1597+ }
1598+
1599+ final chat = new DirectChat(this, this.stream, this.persistence, chatId, participants, Closed);
1600+ chatActivity(chat);
1601+ return chat;
1602+ }
1603+
15641604 /**
15651605 Create a new unconfigured Channel
15661606
......@@ -2126,10 +2166,9 @@ class Client extends EventEmitter {
21262166 }
21272167 final pinnedCount = chat.uiState == Pinned ? 0 : chats.fold((item, result) -> result + (item.uiState == Pinned ? 1 : 0), 0);
21282168 var idx = chats.findIndex(c -> c.chatId == chat.chatId);
2129- if (idx > pinnedCount) {
2130- chats.splice(idx, 1);
2131- chats.insert(pinnedCount, chat);
2132- }
2169+ if (idx >= 0) chats.splice(idx, 1);
2170+ chats.insert(pinnedCount, chat);
2171+ chatsIndex[chat.chatId] = chat;
21332172 if (trigger) this.trigger("chats/update", [chat]);
21342173 }
21352174
+162 -4 test/TestChat.hx #
......@@ -1235,8 +1235,7 @@ class TestChat extends utest.Test {
12351235 final persistence = new RecordingPersistence();
12361236 final client = new Client("me@example.com", persistence);
12371237 final routing = "multi@example.com";
1238- final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, routing);
1239- chat._fullCounterparts = ["bob@example.com", "carol@example.com"];
1238+ final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, routing, ["bob@example.com", "carol@example.com"]);
12401239
12411240 client.stream.on("sendStanza", (stanza: Stanza) -> {
12421241 if (stanza.name != "message" || stanza.getChildText("body") != "hello") return EventUnhandled;
......@@ -1363,8 +1362,7 @@ class TestChat extends utest.Test {
13631362 final persistence = new RecordingPersistence();
13641363 final client = new Client("me@example.com", persistence);
13651364 final routing = "multi@example.com";
1366- final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, routing);
1367- chat._fullCounterparts = ["bob@example.com", "carol@example.com"];
1365+ final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, routing, ["bob@example.com", "carol@example.com"]);
13681366
13691367 client.stream.on("sendStanza", (stanza: Stanza) -> {
13701368 if (stanza.name != "message" || stanza.getChild("reactions", "urn:xmpp:reactions:0") == null) return EventUnhandled;
......@@ -1381,6 +1379,161 @@ class TestChat extends utest.Test {
13811379 chat.removeReaction(reactionTarget(), borogove.Reaction.unicode("👍"));
13821380 }
13831381
1382+ // Creating a multi-person DM from search results must produce the same chat
1383+ // id the message parser derives, so incoming stanzas resolve back to it.
1384+ public function testStartMultiChatExtended() {
1385+ final persistence = new RecordingPersistence();
1386+ final client = new Client("me@example.com", persistence);
1387+
1388+ final chat = client.startMultiChat([availablePerson("carol@example.com"), availablePerson("bob@example.com")]);
1389+
1390+ Assert.equals("bob@example.com\ncarol@example.com", chat.chatId);
1391+ Assert.same(["bob@example.com", "carol@example.com"], chat._fullCounterparts);
1392+ Assert.equals(chat, client.getChat("bob@example.com\ncarol@example.com"));
1393+ Assert.isTrue(client.getChats().contains(chat));
1394+ Assert.isTrue(persistence.storedChats.contains(chat));
1395+ }
1396+
1397+ // Search results for different resources of the same person, our own account,
1398+ // and duplicates must all collapse to a single participant.
1399+ public function testStartMultiChatIgnoresSelfAndDuplicates() {
1400+ final persistence = new Dummy();
1401+ final client = new Client("me@example.com", persistence);
1402+
1403+ final chat = client.startMultiChat([
1404+ availablePerson("me@example.com"),
1405+ availablePerson("bob@example.com"),
1406+ availablePerson("bob@example.com/mobile"),
1407+ availablePerson("carol@example.com")
1408+ ]);
1409+
1410+ Assert.equals("bob@example.com\ncarol@example.com", chat.chatId);
1411+ Assert.same(["bob@example.com", "carol@example.com"], chat._fullCounterparts);
1412+ }
1413+
1414+ // A compat multi-person DM is addressed to a single routing JID while its
1415+ // participants are kept as counterparts (the chat id cannot carry both).
1416+ public function testStartMultiChatCompat() {
1417+ final persistence = new Dummy();
1418+ final client = new Client("me@example.com", persistence);
1419+
1420+ final chat = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")], "multi@example.com");
1421+
1422+ Assert.equals("multi@example.com", chat.chatId);
1423+ Assert.same(["bob@example.com", "carol@example.com"], chat._fullCounterparts);
1424+ Assert.equals(chat, client.getChat("multi@example.com"));
1425+ }
1426+
1427+ // Starting an already-known chat returns it untouched, even when the caller
1428+ // passes a different participant set.
1429+ public function testStartMultiChatReturnsExisting() {
1430+ final persistence = new Dummy();
1431+ final client = new Client("me@example.com", persistence);
1432+
1433+ final first = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")], "multi@example.com");
1434+ final chatCount = client.getChats().length;
1435+
1436+ final second = client.startMultiChat([availablePerson("dave@example.com"), availablePerson("erin@example.com")], "multi@example.com");
1437+
1438+ Assert.equals(first, second);
1439+ Assert.equals(chatCount, client.getChats().length);
1440+ Assert.same(["bob@example.com", "carol@example.com"], second._fullCounterparts);
1441+ }
1442+
1443+ public function testStartMultiChatKeepsOtherChats() {
1444+ final persistence = new Dummy();
1445+ final client = new Client("me@example.com", persistence);
1446+
1447+ final first = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")]);
1448+ final second = client.startMultiChat([availablePerson("dave@example.com"), availablePerson("erin@example.com")]);
1449+
1450+ Assert.isTrue(client.getChats().contains(first));
1451+ Assert.isTrue(client.getChats().contains(second));
1452+ Assert.equals(first, client.getChat("bob@example.com\ncarol@example.com"));
1453+ Assert.equals(second, client.getChat("dave@example.com\nerin@example.com"));
1454+ }
1455+
1456+ public function testStartMultiChatRejectsInvalidTarget() {
1457+ final persistence = new Dummy();
1458+ final client = new Client("me@example.com", persistence);
1459+ final counterparts = [availablePerson("bob@example.com"), availablePerson("carol@example.com")];
1460+
1461+ Assert.raises(() -> client.startMultiChat(counterparts, "multi@example.com/phone"), String);
1462+ Assert.raises(() -> client.startMultiChat(counterparts, "not-a-jid"), String);
1463+ }
1464+
1465+ public function testStartMultiChatTooFewParticipants() {
1466+ final persistence = new Dummy();
1467+ final client = new Client("me@example.com", persistence);
1468+
1469+ Assert.raises(() -> client.startMultiChat([]), String);
1470+ Assert.raises(() -> client.startMultiChat([availablePerson("bob@example.com")]), String);
1471+ // Our own account never counts as a participant.
1472+ Assert.raises(() -> client.startMultiChat([availablePerson("me@example.com"), availablePerson("bob@example.com")]), String);
1473+ }
1474+
1475+ public function testStartMultiChatRejectsChannels() {
1476+ final persistence = new Dummy();
1477+ final client = new Client("me@example.com", persistence);
1478+ final caps = new borogove.Caps("", [new Identity("conference", "text", "Channel")], ["http://jabber.org/protocol/muc"], []);
1479+ final channel = new AvailableChat("room@example.com", "Room", "", caps);
1480+
1481+ Assert.raises(() -> client.startMultiChat([availablePerson("bob@example.com"), channel]), String);
1482+ }
1483+
1484+ public function testStartMultiChatExtendedSendsToEachParticipant(async: Async) {
1485+ final persistence = new RecordingPersistence();
1486+ final client = new Client("me@example.com", persistence);
1487+ final chat = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")]);
1488+ final sent: Array<Stanza> = [];
1489+
1490+ client.stream.on("sendStanza", (stanza: Stanza) -> {
1491+ if (stanza.name != "message" || stanza.getChildText("body") != "hello") return EventUnhandled;
1492+ sent.push(stanza);
1493+ if (sent.length < 2) return EventUnhandled;
1494+
1495+ final tos = sent.map(s -> s.attr.get("to"));
1496+ tos.sort(Reflect.compare);
1497+ Assert.same(["bob@example.com", "carol@example.com"], tos);
1498+ Assert.equals(chat.chatId, persistence.storedMessages[0]?.chatId());
1499+ async.done();
1500+ return EventHandled;
1501+ });
1502+
1503+ final builder = new ChatMessageBuilder();
1504+ builder.text = "hello";
1505+ chat.sendMessage(builder, borogove.Chat.OutgoingE2EEPreference.NoE2EE);
1506+ }
1507+
1508+ public function testStartMultiChatCompatSendsToRoutingJid(async: Async) {
1509+ final persistence = new RecordingPersistence();
1510+ final client = new Client("me@example.com", persistence);
1511+ final chat = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")], "multi@example.com");
1512+
1513+ client.stream.on("sendStanza", (stanza: Stanza) -> {
1514+ if (stanza.name != "message" || stanza.getChildText("body") != "hello") return EventUnhandled;
1515+
1516+ Assert.equals("multi@example.com", stanza.attr.get("to"));
1517+ final addresses = stanza.getChild("addresses", "http://jabber.org/protocol/address");
1518+ Assert.notNull(addresses);
1519+ if (addresses != null) {
1520+ Assert.same(["bob@example.com", "carol@example.com"], addresses.allTags("address").map(a -> a.attr.get("jid")));
1521+ }
1522+ Assert.equals("multi@example.com", persistence.storedMessages[0]?.chatId());
1523+ Assert.same(["bob@example.com", "carol@example.com"], persistence.storedMessages[0]?.recipients.map(r -> r.asBare().asString()));
1524+ async.done();
1525+ return EventHandled;
1526+ });
1527+
1528+ final builder = new ChatMessageBuilder();
1529+ builder.text = "hello";
1530+ chat.sendMessage(builder, borogove.Chat.OutgoingE2EEPreference.NoE2EE);
1531+ }
1532+
1533+ private function availablePerson(chatId: String):AvailableChat {
1534+ return new AvailableChat(chatId, chatId, "", CapsRepo.empty);
1535+ }
1536+
13841537 private function reactionTarget():borogove.ChatMessage {
13851538 final builder = new ChatMessageBuilder();
13861539 builder.localId = "m1";
......@@ -1397,6 +1550,11 @@ class TestChat extends utest.Test {
13971550 @:access(borogove)
13981551 class RecordingPersistence extends Dummy {
13991552 public var storedMessages: Array<borogove.ChatMessage> = [];
1553+ public var storedChats: Array<borogove.Chat> = [];
1554+
1555+ override public function storeChats(accountId: String, chats: Array<borogove.Chat>) {
1556+ storedChats = storedChats.concat(chats);
1557+ }
14001558
14011559 override public function storeMessages(accountId: String, messages: Array<borogove.ChatMessage>) {
14021560 storedMessages = storedMessages.concat(messages);
Back to top