borogove
created pr with
borogove/multi-dm.1
added borogove/multi-dm.2
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 -3checkout specific patchset revision:
ssh pr.pico.sh pull borogove:multi-dm [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh borogove:multi-dmadd comment to patch request:
ssh pr.pico.sh comment borogove:multi-dm "lgtm!"
Patchset
borogove/multi-dm.2
Use to for chatId if not in recipients
Stephen Paul Weber
clone should clone attributes
2026-11-08T19:15:14ZStephen Paul Weber
Actually send to all recipients
2026-11-09T16:18:06ZStephen Paul Weber
Never add self as counterpart
2026-11-09T16:24:50ZStephen Paul Weber
Extended addressing for sent messages specifies all recipients
2026-11-08T19:20:04ZStephen Paul Weber
Always add all recipients to outbound message
2026-11-08T19:20:46ZStephen Paul Weber
Use a shared helper for adding extended addressing
2026-11-08T19:21:50ZStephen Paul Weber
→ Add startMultiChat to create multiDMs
2026-11-08T19:32:30ZStephen Paul Weber
Never include ourselves in incoming chat ids
2026-11-08T21:01:35ZStephen Paul Weber
Cannot MAM or bookmark a multiDM
2026-11-09T19:40:59ZStephen Paul Weber
2026-11-10T17:34:01Z
Add startMultiChat to create multiDMs
Stephen Paul Weber
2026-11-08T21:01:35ZSemantic diff summary
0 added,
0 modified,
0 signature changed,
0 removed
across 0 analyzed files
(3 files skipped: unsupported file type)
+3
-3
borogove/Chat.hx
#
| ... | ... | @@ -1108,9 +1108,9 @@ class DirectChat extends Chat { | |
| 1108 | 1108 | private var _fullCounterparts: Array<String>; | |
| 1109 | 1109 | ||
| 1110 | 1110 | @: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) { | |
| 1112 | 1112 | super(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs); | |
| 1113 | - | _fullCounterparts = counterparts(); | |
| 1113 | + | _fullCounterparts = fullCounterparts ?? counterparts(); | |
| 1114 | 1114 | outbox.start(); | |
| 1115 | 1115 | } | |
| 1116 | 1116 |
| ... | ... | @@ -2856,7 +2856,7 @@ class SerializedChat { | |
| 2856 | 2856 | } | |
| 2857 | 2857 | ||
| 2858 | 2858 | 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); | |
| 2860 | 2860 | } else if (klass == "Channel") { | |
| 2861 | 2861 | final channel = new Channel(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensions, readUpToId, readUpToBy); | |
| 2862 | 2862 | channel.membersForName = membersForName; |
+43
-4
borogove/Client.hx
#
| ... | ... | @@ -1561,6 +1561,46 @@ class Client extends EventEmitter { | |
| 1561 | 1561 | return chat; | |
| 1562 | 1562 | } | |
| 1563 | 1563 | ||
| 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 | + | ||
| 1564 | 1604 | /** | |
| 1565 | 1605 | Create a new unconfigured Channel | |
| 1566 | 1606 |
| ... | ... | @@ -2126,10 +2166,9 @@ class Client extends EventEmitter { | |
| 2126 | 2166 | } | |
| 2127 | 2167 | final pinnedCount = chat.uiState == Pinned ? 0 : chats.fold((item, result) -> result + (item.uiState == Pinned ? 1 : 0), 0); | |
| 2128 | 2168 | 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; | |
| 2133 | 2172 | if (trigger) this.trigger("chats/update", [chat]); | |
| 2134 | 2173 | } | |
| 2135 | 2174 |
+162
-4
test/TestChat.hx
#
| ... | ... | @@ -1235,8 +1235,7 @@ class TestChat extends utest.Test { | |
| 1235 | 1235 | final persistence = new RecordingPersistence(); | |
| 1236 | 1236 | final client = new Client("me@example.com", persistence); | |
| 1237 | 1237 | 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"]); | |
| 1240 | 1239 | ||
| 1241 | 1240 | client.stream.on("sendStanza", (stanza: Stanza) -> { | |
| 1242 | 1241 | if (stanza.name != "message" || stanza.getChildText("body") != "hello") return EventUnhandled; |
| ... | ... | @@ -1363,8 +1362,7 @@ class TestChat extends utest.Test { | |
| 1363 | 1362 | final persistence = new RecordingPersistence(); | |
| 1364 | 1363 | final client = new Client("me@example.com", persistence); | |
| 1365 | 1364 | 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"]); | |
| 1368 | 1366 | ||
| 1369 | 1367 | client.stream.on("sendStanza", (stanza: Stanza) -> { | |
| 1370 | 1368 | if (stanza.name != "message" || stanza.getChild("reactions", "urn:xmpp:reactions:0") == null) return EventUnhandled; |
| ... | ... | @@ -1381,6 +1379,161 @@ class TestChat extends utest.Test { | |
| 1381 | 1379 | chat.removeReaction(reactionTarget(), borogove.Reaction.unicode("👍")); | |
| 1382 | 1380 | } | |
| 1383 | 1381 | ||
| 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 | + | ||
| 1384 | 1537 | private function reactionTarget():borogove.ChatMessage { | |
| 1385 | 1538 | final builder = new ChatMessageBuilder(); | |
| 1386 | 1539 | builder.localId = "m1"; |
| ... | ... | @@ -1397,6 +1550,11 @@ class TestChat extends utest.Test { | |
| 1397 | 1550 | @:access(borogove) | |
| 1398 | 1551 | class RecordingPersistence extends Dummy { | |
| 1399 | 1552 | 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 | + | } | |
| 1400 | 1558 | ||
| 1401 | 1559 | override public function storeMessages(accountId: String, messages: Array<borogove.ChatMessage>) { | |
| 1402 | 1560 | storedMessages = storedMessages.concat(messages); |