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 #
......@@ -1107,9 +1107,9 @@ class DirectChat extends Chat {
11071107 private var _fullCounterparts: Array<String>;
11081108
11091109 @:allow(borogove)
1110- 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) {
1110+ 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) {
11111111 super(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs);
1112- _fullCounterparts = counterparts();
1112+ _fullCounterparts = fullCounterparts ?? counterparts();
11131113 outbox.start();
11141114 }
11151115
......@@ -2841,7 +2841,7 @@ class SerializedChat {
28412841 }
28422842
28432843 final chat = if (klass == "DirectChat") {
2844- new DirectChat(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs);
2844+ new DirectChat(client, stream, persistence, chatId, null, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs);
28452845 } else if (klass == "Channel") {
28462846 final channel = new Channel(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy);
28472847 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
......@@ -2123,10 +2163,9 @@ class Client extends EventEmitter {
21232163 }
21242164 final pinnedCount = chat.uiState == Pinned ? 0 : chats.fold((item, result) -> result + (item.uiState == Pinned ? 1 : 0), 0);
21252165 var idx = chats.findIndex(c -> c.chatId == chat.chatId);
2126- if (idx > pinnedCount) {
2127- chats.splice(idx, 1);
2128- chats.insert(pinnedCount, chat);
2129- }
2166+ if (idx >= 0) chats.splice(idx, 1);
2167+ chats.insert(pinnedCount, chat);
2168+ chatsIndex[chat.chatId] = chat;
21302169 if (trigger) this.trigger("chats/update", [chat]);
21312170 }
21322171
+162 -4 test/TestChat.hx #
......@@ -1171,8 +1171,7 @@ class TestChat extends utest.Test {
11711171 final persistence = new RecordingPersistence();
11721172 final client = new Client("me@example.com", persistence);
11731173 final routing = "multi@example.com";
1174- final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, routing);
1175- chat._fullCounterparts = ["bob@example.com", "carol@example.com"];
1174+ final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, routing, ["bob@example.com", "carol@example.com"]);
11761175
11771176 client.stream.on("sendStanza", (stanza: Stanza) -> {
11781177 if (stanza.name != "message" || stanza.getChildText("body") != "hello") return EventUnhandled;
......@@ -1299,8 +1298,7 @@ class TestChat extends utest.Test {
12991298 final persistence = new RecordingPersistence();
13001299 final client = new Client("me@example.com", persistence);
13011300 final routing = "multi@example.com";
1302- final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, routing);
1303- chat._fullCounterparts = ["bob@example.com", "carol@example.com"];
1301+ final chat = new borogove.Chat.DirectChat(client, client.stream, persistence, routing, ["bob@example.com", "carol@example.com"]);
13041302
13051303 client.stream.on("sendStanza", (stanza: Stanza) -> {
13061304 if (stanza.name != "message" || stanza.getChild("reactions", "urn:xmpp:reactions:0") == null) return EventUnhandled;
......@@ -1317,6 +1315,161 @@ class TestChat extends utest.Test {
13171315 chat.removeReaction(reactionTarget(), borogove.Reaction.unicode("👍"));
13181316 }
13191317
1318+ // Creating a multi-person DM from search results must produce the same chat
1319+ // id the message parser derives, so incoming stanzas resolve back to it.
1320+ public function testStartMultiChatExtended() {
1321+ final persistence = new RecordingPersistence();
1322+ final client = new Client("me@example.com", persistence);
1323+
1324+ final chat = client.startMultiChat([availablePerson("carol@example.com"), availablePerson("bob@example.com")]);
1325+
1326+ Assert.equals("bob@example.com\ncarol@example.com", chat.chatId);
1327+ Assert.same(["bob@example.com", "carol@example.com"], chat._fullCounterparts);
1328+ Assert.equals(chat, client.getChat("bob@example.com\ncarol@example.com"));
1329+ Assert.isTrue(client.getChats().contains(chat));
1330+ Assert.isTrue(persistence.storedChats.contains(chat));
1331+ }
1332+
1333+ // Search results for different resources of the same person, our own account,
1334+ // and duplicates must all collapse to a single participant.
1335+ public function testStartMultiChatIgnoresSelfAndDuplicates() {
1336+ final persistence = new Dummy();
1337+ final client = new Client("me@example.com", persistence);
1338+
1339+ final chat = client.startMultiChat([
1340+ availablePerson("me@example.com"),
1341+ availablePerson("bob@example.com"),
1342+ availablePerson("bob@example.com/mobile"),
1343+ availablePerson("carol@example.com")
1344+ ]);
1345+
1346+ Assert.equals("bob@example.com\ncarol@example.com", chat.chatId);
1347+ Assert.same(["bob@example.com", "carol@example.com"], chat._fullCounterparts);
1348+ }
1349+
1350+ // A compat multi-person DM is addressed to a single routing JID while its
1351+ // participants are kept as counterparts (the chat id cannot carry both).
1352+ public function testStartMultiChatCompat() {
1353+ final persistence = new Dummy();
1354+ final client = new Client("me@example.com", persistence);
1355+
1356+ final chat = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")], "multi@example.com");
1357+
1358+ Assert.equals("multi@example.com", chat.chatId);
1359+ Assert.same(["bob@example.com", "carol@example.com"], chat._fullCounterparts);
1360+ Assert.equals(chat, client.getChat("multi@example.com"));
1361+ }
1362+
1363+ // Starting an already-known chat returns it untouched, even when the caller
1364+ // passes a different participant set.
1365+ public function testStartMultiChatReturnsExisting() {
1366+ final persistence = new Dummy();
1367+ final client = new Client("me@example.com", persistence);
1368+
1369+ final first = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")], "multi@example.com");
1370+ final chatCount = client.getChats().length;
1371+
1372+ final second = client.startMultiChat([availablePerson("dave@example.com"), availablePerson("erin@example.com")], "multi@example.com");
1373+
1374+ Assert.equals(first, second);
1375+ Assert.equals(chatCount, client.getChats().length);
1376+ Assert.same(["bob@example.com", "carol@example.com"], second._fullCounterparts);
1377+ }
1378+
1379+ public function testStartMultiChatKeepsOtherChats() {
1380+ final persistence = new Dummy();
1381+ final client = new Client("me@example.com", persistence);
1382+
1383+ final first = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")]);
1384+ final second = client.startMultiChat([availablePerson("dave@example.com"), availablePerson("erin@example.com")]);
1385+
1386+ Assert.isTrue(client.getChats().contains(first));
1387+ Assert.isTrue(client.getChats().contains(second));
1388+ Assert.equals(first, client.getChat("bob@example.com\ncarol@example.com"));
1389+ Assert.equals(second, client.getChat("dave@example.com\nerin@example.com"));
1390+ }
1391+
1392+ public function testStartMultiChatRejectsInvalidTarget() {
1393+ final persistence = new Dummy();
1394+ final client = new Client("me@example.com", persistence);
1395+ final counterparts = [availablePerson("bob@example.com"), availablePerson("carol@example.com")];
1396+
1397+ Assert.raises(() -> client.startMultiChat(counterparts, "multi@example.com/phone"), String);
1398+ Assert.raises(() -> client.startMultiChat(counterparts, "not-a-jid"), String);
1399+ }
1400+
1401+ public function testStartMultiChatTooFewParticipants() {
1402+ final persistence = new Dummy();
1403+ final client = new Client("me@example.com", persistence);
1404+
1405+ Assert.raises(() -> client.startMultiChat([]), String);
1406+ Assert.raises(() -> client.startMultiChat([availablePerson("bob@example.com")]), String);
1407+ // Our own account never counts as a participant.
1408+ Assert.raises(() -> client.startMultiChat([availablePerson("me@example.com"), availablePerson("bob@example.com")]), String);
1409+ }
1410+
1411+ public function testStartMultiChatRejectsChannels() {
1412+ final persistence = new Dummy();
1413+ final client = new Client("me@example.com", persistence);
1414+ final caps = new borogove.Caps("", [new Identity("conference", "text", "Channel")], ["http://jabber.org/protocol/muc"], []);
1415+ final channel = new AvailableChat("room@example.com", "Room", "", caps);
1416+
1417+ Assert.raises(() -> client.startMultiChat([availablePerson("bob@example.com"), channel]), String);
1418+ }
1419+
1420+ public function testStartMultiChatExtendedSendsToEachParticipant(async: Async) {
1421+ final persistence = new RecordingPersistence();
1422+ final client = new Client("me@example.com", persistence);
1423+ final chat = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")]);
1424+ final sent: Array<Stanza> = [];
1425+
1426+ client.stream.on("sendStanza", (stanza: Stanza) -> {
1427+ if (stanza.name != "message" || stanza.getChildText("body") != "hello") return EventUnhandled;
1428+ sent.push(stanza);
1429+ if (sent.length < 2) return EventUnhandled;
1430+
1431+ final tos = sent.map(s -> s.attr.get("to"));
1432+ tos.sort(Reflect.compare);
1433+ Assert.same(["bob@example.com", "carol@example.com"], tos);
1434+ Assert.equals(chat.chatId, persistence.storedMessages[0]?.chatId());
1435+ async.done();
1436+ return EventHandled;
1437+ });
1438+
1439+ final builder = new ChatMessageBuilder();
1440+ builder.text = "hello";
1441+ chat.sendMessage(builder, borogove.Chat.OutgoingE2EEPreference.NoE2EE);
1442+ }
1443+
1444+ public function testStartMultiChatCompatSendsToRoutingJid(async: Async) {
1445+ final persistence = new RecordingPersistence();
1446+ final client = new Client("me@example.com", persistence);
1447+ final chat = client.startMultiChat([availablePerson("bob@example.com"), availablePerson("carol@example.com")], "multi@example.com");
1448+
1449+ client.stream.on("sendStanza", (stanza: Stanza) -> {
1450+ if (stanza.name != "message" || stanza.getChildText("body") != "hello") return EventUnhandled;
1451+
1452+ Assert.equals("multi@example.com", stanza.attr.get("to"));
1453+ final addresses = stanza.getChild("addresses", "http://jabber.org/protocol/address");
1454+ Assert.notNull(addresses);
1455+ if (addresses != null) {
1456+ Assert.same(["bob@example.com", "carol@example.com"], addresses.allTags("address").map(a -> a.attr.get("jid")));
1457+ }
1458+ Assert.equals("multi@example.com", persistence.storedMessages[0]?.chatId());
1459+ Assert.same(["bob@example.com", "carol@example.com"], persistence.storedMessages[0]?.recipients.map(r -> r.asBare().asString()));
1460+ async.done();
1461+ return EventHandled;
1462+ });
1463+
1464+ final builder = new ChatMessageBuilder();
1465+ builder.text = "hello";
1466+ chat.sendMessage(builder, borogove.Chat.OutgoingE2EEPreference.NoE2EE);
1467+ }
1468+
1469+ private function availablePerson(chatId: String):AvailableChat {
1470+ return new AvailableChat(chatId, chatId, "", CapsRepo.empty);
1471+ }
1472+
13201473 private function reactionTarget():borogove.ChatMessage {
13211474 final builder = new ChatMessageBuilder();
13221475 builder.localId = "m1";
......@@ -1333,6 +1486,11 @@ class TestChat extends utest.Test {
13331486 @:access(borogove)
13341487 class RecordingPersistence extends Dummy {
13351488 public var storedMessages: Array<borogove.ChatMessage> = [];
1489+ public var storedChats: Array<borogove.Chat> = [];
1490+
1491+ override public function storeChats(accountId: String, chats: Array<borogove.Chat>) {
1492+ storedChats = storedChats.concat(chats);
1493+ }
13361494
13371495 override public function storeMessages(accountId: String, messages: Array<borogove.ChatMessage>) {
13381496 storedMessages = storedMessages.concat(messages);
Back to top