borogove
created pr with
borogove/muc-create.1
added borogove/muc-create.2
1: 7eacfb3 < -: ------- Initial channel creation
2: 4b5a982 ! 1: d85d8d1 review
3: 8c9556b < -: ------- Support intant room creation
4: 941aad7 < -: ------- Add tests
added borogove/muc-create.3
-: ------- > 1: 0aa766b Initial channel creation
1: d85d8d1 ! 2: a7f33d5 After config form is submitted, creation is complete
-: ------- > 3: e6042b0 Support intant room creation
-: ------- > 4: 366bac7 Add tests
-: ------- > 5: fece298 review
-: ------- > 6: 322b2bd Review replies
added borogove/muc-create.4
1: 0aa766b = 1: 0aa766b Initial channel creation
2: a7f33d5 = 2: a7f33d5 After config form is submitted, creation is complete
3: e6042b0 = 3: e6042b0 Support intant room creation
4: 366bac7 ! 4: 0da1c7e Add tests
5: fece298 = 5: 86bad8e review
6: 322b2bd = 6: 997cf5a Review replies
commented
lgtm!
cmds
checkout latest patchset:
ssh pr.pico.sh pull borogove:muc-create | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull borogove:muc-create [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh borogove:muc-createadd comment to patch request:
ssh pr.pico.sh comment borogove:muc-create "lgtm!"
Patchset
borogove/muc-create.1
Initial channel creation
Stephen Paul Weber
2026-10-28T19:34:47ZCreate a new Channel from some options and set it to a "Creating" state, start the creation process with the server. Do not store a "Creating" channel in the database. It does not yet have a way to complete the process.
Semantic diff summary
0 added,
0 modified,
0 signature changed,
0 removed
across 0 analyzed files
(3 files skipped: unsupported file type)
+72
-0
borogove/ChannelCreateOptions.hx
#
| ... | ... | @@ -0,0 +1,72 @@ | |
| 1 | + | package borogove; | |
| 2 | + | ||
| 3 | + | using StringTools; | |
| 4 | + | ||
| 5 | + | #if cpp | |
| 6 | + | import HaxeCBridge; | |
| 7 | + | #end | |
| 8 | + | ||
| 9 | + | @:expose | |
| 10 | + | @:nullSafety(StrictThreaded) | |
| 11 | + | #if cpp | |
| 12 | + | @:build(HaxeCBridge.expose()) | |
| 13 | + | @:build(HaxeSwiftBridge.expose()) | |
| 14 | + | #end | |
| 15 | + | class ChannelCreateOptions { | |
| 16 | + | /** | |
| 17 | + | Service to create the channel on | |
| 18 | + | ||
| 19 | + | Will use the account default if not set | |
| 20 | + | **/ | |
| 21 | + | public var service: Null<String> = null; | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | Human-readable name for the channel. | |
| 25 | + | **/ | |
| 26 | + | public var name: Null<String> = null; | |
| 27 | + | ||
| 28 | + | /** | |
| 29 | + | Localpart of the room ID | |
| 30 | + | **/ | |
| 31 | + | public var localpart: Null<String> = null; | |
| 32 | + | ||
| 33 | + | /** | |
| 34 | + | Whether the room should be members only | |
| 35 | + | **/ | |
| 36 | + | public var isPrivate: Bool = true; | |
| 37 | + | ||
| 38 | + | /** | |
| 39 | + | When true, leave the room locked after creation and let the caller | |
| 40 | + | submit the full owner configuration form before it is unlocked. | |
| 41 | + | **/ | |
| 42 | + | public var deferConfig: Bool = false; | |
| 43 | + | ||
| 44 | + | public function new() {} | |
| 45 | + | ||
| 46 | + | /** | |
| 47 | + | Build the room JID for this channel on a MUC service. | |
| 48 | + | ||
| 49 | + | Uses `localpart` when supplied, otherwise derives one from `name`, and | |
| 50 | + | finally falls back to a generated UUID. | |
| 51 | + | ||
| 52 | + | @param service MUC service domain, for example `groups.example.com` | |
| 53 | + | @returns the room JID, for example `team@groups.example.com` | |
| 54 | + | **/ | |
| 55 | + | public function jid(service: String) { | |
| 56 | + | final requested = localpart; | |
| 57 | + | var local = requested != null && requested.trim() != "" ? requested : slug(name); | |
| 58 | + | if (local == null || local == "") local = ID.unique(); | |
| 59 | + | ||
| 60 | + | return new JID(local, service); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | private static function slug(name: Null<String>): Null<String> { | |
| 64 | + | if (name == null) return null; | |
| 65 | + | ||
| 66 | + | var result = name.trim().toLowerCase(); | |
| 67 | + | result = ~/[^a-z0-9]+/g.replace(result, "-"); | |
| 68 | + | result = ~/^-+|-+$/g.replace(result, ""); | |
| 69 | + | ||
| 70 | + | return result == "" ? null : result; | |
| 71 | + | } | |
| 72 | + | } |
+37
-6
borogove/Chat.hx
#
| ... | ... | @@ -1564,7 +1565,7 @@ class Channel extends Chat { | |
| 1564 | 1565 | ||
| 1565 | 1566 | @:allow(borogove) | |
| 1566 | 1567 | private function selfPing(refresh: Bool) { | |
| 1567 | - | if (uiState == Invited) return; | |
| 1568 | + | if (uiState == Invited || uiState == Creating) return; | |
| 1568 | 1569 | ||
| 1569 | 1570 | if (uiState == Closed) { | |
| 1570 | 1571 | client.channelPinger.remove(this); |
| ... | ... | @@ -1605,7 +1606,7 @@ class Channel extends Chat { | |
| 1605 | 1606 | } | |
| 1606 | 1607 | ||
| 1607 | 1608 | @:allow(borogove) | |
| 1608 | - | private function join(justCleared = false): Promise<Any> { | |
| 1609 | + | private function join(justCleared = false, sync = true): Promise<Any> { | |
| 1609 | 1610 | if (uiState == Invited || uiState == Closed) { | |
| 1610 | 1611 | // Do not join | |
| 1611 | 1612 | return Promise.resolve(null); |
| ... | ... | @@ -1634,12 +1635,28 @@ class Channel extends Chat { | |
| 1634 | 1635 | return stanza; | |
| 1635 | 1636 | } | |
| 1636 | 1637 | ); | |
| 1637 | - | persistence.syncPoint(client.accountId(), chatId).then((point) -> doSync(point)); | |
| 1638 | + | if (sync) persistence.syncPoint(client.accountId(), chatId).then((point) -> doSync(point)); | |
| 1638 | 1639 | ||
| 1639 | 1640 | return null; | |
| 1640 | 1641 | }); | |
| 1641 | 1642 | } | |
| 1642 | 1643 | ||
| 1644 | + | @:allow(borogove) | |
| 1645 | + | private function setJoinFailed(stanza: Stanza): Void { | |
| 1646 | + | joinFailed = stanza; | |
| 1647 | + | if (uiState == Creating) { | |
| 1648 | + | uiState = Closed; | |
| 1649 | + | client.sendPresence( | |
| 1650 | + | getFullJid().asString(), | |
| 1651 | + | (stanza) -> { | |
| 1652 | + | stanza.attr.set("type", "unavailable"); | |
| 1653 | + | return stanza; | |
| 1654 | + | } | |
| 1655 | + | ); | |
| 1656 | + | disconnected(); | |
| 1657 | + | } | |
| 1658 | + | } | |
| 1659 | + | ||
| 1643 | 1660 | private function selfPingSuccess() { | |
| 1644 | 1661 | if (nickInUse() != client.displayName()) { | |
| 1645 | 1662 | final desiredFullJid = JID.parse(chatId).withResource(client.displayName()); |
| ... | ... | @@ -1823,6 +1841,7 @@ class Channel extends Chat { | |
| 1823 | 1841 | ||
| 1824 | 1842 | @:allow(borogove) | |
| 1825 | 1843 | private function setPresence(resource:String, presence:Presence, noStore:Bool) { | |
| 1844 | + | final wasCreating = uiState == Creating; | |
| 1826 | 1845 | final oneTen = presence?.mucUser?.statusCodes?.find((status) -> status == "110"); | |
| 1827 | 1846 | final member = buildMember(resource, presence); | |
| 1828 | 1847 | if (presence.mucUser != null && oneTen == null && member.isSelf) { |
| ... | ... | @@ -1833,7 +1852,7 @@ class Channel extends Chat { | |
| 1833 | 1852 | return; | |
| 1834 | 1853 | } | |
| 1835 | 1854 | final occupantId = (presence : Stanza).getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id"); | |
| 1836 | - | if (!noStore && !(disco.features.contains("urn:xmpp:occupant-id:0") && member.id != chatId + "/" + occupantId)) { | |
| 1855 | + | if (!noStore && !wasCreating && !(disco.features.contains("urn:xmpp:occupant-id:0") && member.id != chatId + "/" + occupantId)) { | |
| 1837 | 1856 | if (presence.type == "unavailable" && member.photoUri == null) { | |
| 1838 | 1857 | final memberUpdates = MemberUpdate.extractUpdates(client.accountId(), this, presence); | |
| 1839 | 1858 | persistence.storeMemberUpdates(client.accountId(), this, memberUpdates, false).then(members -> { |
| ... | ... | @@ -1853,9 +1872,9 @@ class Channel extends Chat { | |
| 1853 | 1872 | } | |
| 1854 | 1873 | if (mav?.attr?.get("until") != null && mavUntil != mav?.attr?.get("until")) { | |
| 1855 | 1874 | mavUntil = mav?.attr?.get("until"); | |
| 1856 | - | if (!noStore) persistence.storeChats(client.accountId(), [this]); | |
| 1875 | + | if (!noStore && !wasCreating) persistence.storeChats(client.accountId(), [this]); | |
| 1857 | 1876 | } | |
| 1858 | - | if (!noStore && mucUser.role != "visitor" && mucUser.jid != null) { | |
| 1877 | + | if (!noStore && !wasCreating && mucUser.role != "visitor" && mucUser.jid != null) { | |
| 1859 | 1878 | // If they're not a visitor they can't be requesting voice | |
| 1860 | 1879 | persistence.storeVoiceRequest(client.accountId(), this, mucUser.jid.asBare().asString(), false).then(_ -> { | |
| 1861 | 1880 | client.trigger("chats/update", [this]); |
| ... | ... | @@ -1865,6 +1884,17 @@ class Channel extends Chat { | |
| 1865 | 1884 | if (member.isSelf) { | |
| 1866 | 1885 | if (presence.type == "unavailable") { | |
| 1867 | 1886 | disconnected(); | |
| 1887 | + | } else if (wasCreating) { | |
| 1888 | + | self = member; | |
| 1889 | + | if (!isJoined && (presence.mucUser?.statusCodes ?? []).indexOf("201") < 0) { | |
| 1890 | + | setJoinFailed( | |
| 1891 | + | new Stanza("presence", { type: "error" }) | |
| 1892 | + | .tag("error", { type: "cancel" }) | |
| 1893 | + | .tag("conflict", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" }) | |
| 1894 | + | ); | |
| 1895 | + | } else { | |
| 1896 | + | isJoined = true; | |
| 1897 | + | } | |
| 1868 | 1898 | } else { | |
| 1869 | 1899 | joined(member); | |
| 1870 | 1900 | } |
+33
-1
borogove/Client.hx
#
| ... | ... | @@ -471,7 +471,7 @@ class Client extends EventEmitter { | |
| 471 | 471 | final channel = Std.downcast(chat, Channel); | |
| 472 | 472 | if (channel != null && from.asString() == channel.getFullJid().asString()) { | |
| 473 | 473 | trace("Join failed", channel.chatId, stanza); | |
| 474 | - | channel.joinFailed = stanza; | |
| 474 | + | channel.setJoinFailed(stanza); | |
| 475 | 475 | this.trigger("chats/update", [channel]); | |
| 476 | 476 | } | |
| 477 | 477 | } |
| ... | ... | @@ -1135,6 +1135,7 @@ class Client extends EventEmitter { | |
| 1135 | 1135 | // We resumed from disk so these must have been synced before | |
| 1136 | 1136 | // and will get anything since live | |
| 1137 | 1137 | for (channel in getChannels()) { | |
| 1138 | + | if (channel.uiState == Creating) continue; | |
| 1138 | 1139 | channel.inSync = channel.livePresence() && channel.sortId != null; | |
| 1139 | 1140 | trace( | |
| 1140 | 1141 | "SYNC: channel inSync", |
| ... | ... | @@ -1555,6 +1556,35 @@ class Client extends EventEmitter { | |
| 1555 | 1556 | return chat; | |
| 1556 | 1557 | } | |
| 1557 | 1558 | ||
| 1559 | + | /** | |
| 1560 | + | Create a new Channel | |
| 1561 | + | ||
| 1562 | + | @param options channel creation options | |
| 1563 | + | @returns A promise resolving to the newly created Channel, in the `Creating` state | |
| 1564 | + | **/ | |
| 1565 | + | public function createChannel(options: ChannelCreateOptions): Promise<Channel> { | |
| 1566 | + | return (if (options.service != null) { | |
| 1567 | + | Promise.resolve(options.service); | |
| 1568 | + | } else { | |
| 1569 | + | persistence.findServicesWithFeature(accountId(), "http://jabber.org/protocol/muc").then(services -> services[0]?.serviceId); | |
| 1570 | + | }).then((service) -> { | |
| 1571 | + | if (service == null) throw "No default service on which to make a channel"; | |
| 1572 | + | ||
| 1573 | + | final chatId = options.jid(service).asString(); | |
| 1574 | + | if (getChat(chatId) != null) throw 'Chat already exists: ${chatId}'; | |
| 1575 | + | ||
| 1576 | + | final channel = new Channel(this, this.stream, this.persistence, chatId, Creating, false, false, null); | |
| 1577 | + | channel.displayName = options.name ?? options.localpart ?? chatId; | |
| 1578 | + | channel.join(false, false); | |
| 1579 | + | ||
| 1580 | + | chats.unshift(channel); | |
| 1581 | + | chatsIndex[channel.chatId] = channel; | |
| 1582 | + | this.trigger("chats/update", [channel]); | |
| 1583 | + | ||
| 1584 | + | return channel; | |
| 1585 | + | }); | |
| 1586 | + | } | |
| 1587 | + | ||
| 1558 | 1588 | /** | |
| 1559 | 1589 | Find a chat by id | |
| 1560 | 1590 |
| ... | ... | @@ -2589,6 +2619,7 @@ class Client extends EventEmitter { | |
| 2589 | 2619 | ||
| 2590 | 2620 | private function pingAllChannels(refresh: Bool) { | |
| 2591 | 2621 | for (channel in getChannels()) { | |
| 2622 | + | if (channel.uiState == Creating) continue; | |
| 2592 | 2623 | channel.selfPing(refresh || channel.disco == null); | |
| 2593 | 2624 | } | |
| 2594 | 2625 | } |
| ... | ... | @@ -2596,6 +2627,7 @@ class Client extends EventEmitter { | |
| 2596 | 2627 | private function joinAllChannels() { | |
| 2597 | 2628 | channelPinger.clear(); | |
| 2598 | 2629 | for (channel in getChannels()) { | |
| 2630 | + | if (channel.uiState == Creating) continue; | |
| 2599 | 2631 | if (channel.disco.identities.length < 1) { | |
| 2600 | 2632 | channel.refreshDisco(() -> { | |
| 2601 | 2633 | channel.join(true); |