borogove

created pr with borogove/muc-create.1 on 2026-10-28T20:49:38Z · by 9e834e0d
added borogove/muc-create.2 on 2026-10-30T12:26:53Z · by 3c31bc8c
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 on 2026-11-03T03:40:00Z · by 9e834e0d
-: ------- > 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 on 2026-11-03T03:42:40Z · by 9e834e0d
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 on 2026-11-09T14:24:06Z · by 3c31bc8c
lgtm!
cmds
checkout latest patchset:
ssh pr.pico.sh pull borogove:muc-create | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull borogove:muc-create [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh borogove:muc-create
add comment to patch request:
ssh pr.pico.sh comment borogove:muc-create "lgtm!"

Patchset borogove/muc-create.1 on 2026-10-28T20:49:38Z · commit 7eacfb3

Initial channel creation
Stephen Paul Weber 2026-10-28T19:34:47Z
Create 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 #
......@@ -77,6 +77,7 @@ enum abstract UiState(Int) {
7777 var Open; // or Unspecified
7878 var Closed; // Archived
7979 var Invited;
80+ var Creating; // Room creation in progress
8081 }
8182
8283 /**
......@@ -1564,7 +1565,7 @@ class Channel extends Chat {
15641565
15651566 @:allow(borogove)
15661567 private function selfPing(refresh: Bool) {
1567- if (uiState == Invited) return;
1568+ if (uiState == Invited || uiState == Creating) return;
15681569
15691570 if (uiState == Closed) {
15701571 client.channelPinger.remove(this);
......@@ -1605,7 +1606,7 @@ class Channel extends Chat {
16051606 }
16061607
16071608 @:allow(borogove)
1608- private function join(justCleared = false): Promise<Any> {
1609+ private function join(justCleared = false, sync = true): Promise<Any> {
16091610 if (uiState == Invited || uiState == Closed) {
16101611 // Do not join
16111612 return Promise.resolve(null);
......@@ -1634,12 +1635,28 @@ class Channel extends Chat {
16341635 return stanza;
16351636 }
16361637 );
1637- persistence.syncPoint(client.accountId(), chatId).then((point) -> doSync(point));
1638+ if (sync) persistence.syncPoint(client.accountId(), chatId).then((point) -> doSync(point));
16381639
16391640 return null;
16401641 });
16411642 }
16421643
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+
16431660 private function selfPingSuccess() {
16441661 if (nickInUse() != client.displayName()) {
16451662 final desiredFullJid = JID.parse(chatId).withResource(client.displayName());
......@@ -1728,6 +1745,7 @@ class Channel extends Chat {
17281745 }
17291746
17301747 override public function canSend() {
1748+ if (uiState == Creating) return false;
17311749 if (!super.canSend()) return false;
17321750
17331751 final it = self.presence.iterator();
......@@ -1823,6 +1841,7 @@ class Channel extends Chat {
18231841
18241842 @:allow(borogove)
18251843 private function setPresence(resource:String, presence:Presence, noStore:Bool) {
1844+ final wasCreating = uiState == Creating;
18261845 final oneTen = presence?.mucUser?.statusCodes?.find((status) -> status == "110");
18271846 final member = buildMember(resource, presence);
18281847 if (presence.mucUser != null && oneTen == null && member.isSelf) {
......@@ -1833,7 +1852,7 @@ class Channel extends Chat {
18331852 return;
18341853 }
18351854 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)) {
18371856 if (presence.type == "unavailable" && member.photoUri == null) {
18381857 final memberUpdates = MemberUpdate.extractUpdates(client.accountId(), this, presence);
18391858 persistence.storeMemberUpdates(client.accountId(), this, memberUpdates, false).then(members -> {
......@@ -1853,9 +1872,9 @@ class Channel extends Chat {
18531872 }
18541873 if (mav?.attr?.get("until") != null && mavUntil != mav?.attr?.get("until")) {
18551874 mavUntil = mav?.attr?.get("until");
1856- if (!noStore) persistence.storeChats(client.accountId(), [this]);
1875+ if (!noStore && !wasCreating) persistence.storeChats(client.accountId(), [this]);
18571876 }
1858- if (!noStore && mucUser.role != "visitor" && mucUser.jid != null) {
1877+ if (!noStore && !wasCreating && mucUser.role != "visitor" && mucUser.jid != null) {
18591878 // If they're not a visitor they can't be requesting voice
18601879 persistence.storeVoiceRequest(client.accountId(), this, mucUser.jid.asBare().asString(), false).then(_ -> {
18611880 client.trigger("chats/update", [this]);
......@@ -1865,6 +1884,17 @@ class Channel extends Chat {
18651884 if (member.isSelf) {
18661885 if (presence.type == "unavailable") {
18671886 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+ }
18681898 } else {
18691899 joined(member);
18701900 }
......@@ -2115,6 +2145,7 @@ class Channel extends Chat {
21152145 }
21162146
21172147 override public function syncing() {
2148+ if (uiState == Creating) return true;
21182149 if (joinFailed != null) return false;
21192150
21202151 return sync != null || !livePresence();
+33 -1 borogove/Client.hx #
......@@ -471,7 +471,7 @@ class Client extends EventEmitter {
471471 final channel = Std.downcast(chat, Channel);
472472 if (channel != null && from.asString() == channel.getFullJid().asString()) {
473473 trace("Join failed", channel.chatId, stanza);
474- channel.joinFailed = stanza;
474+ channel.setJoinFailed(stanza);
475475 this.trigger("chats/update", [channel]);
476476 }
477477 }
......@@ -1135,6 +1135,7 @@ class Client extends EventEmitter {
11351135 // We resumed from disk so these must have been synced before
11361136 // and will get anything since live
11371137 for (channel in getChannels()) {
1138+ if (channel.uiState == Creating) continue;
11381139 channel.inSync = channel.livePresence() && channel.sortId != null;
11391140 trace(
11401141 "SYNC: channel inSync",
......@@ -1555,6 +1556,35 @@ class Client extends EventEmitter {
15551556 return chat;
15561557 }
15571558
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+
15581588 /**
15591589 Find a chat by id
15601590
......@@ -2589,6 +2619,7 @@ class Client extends EventEmitter {
25892619
25902620 private function pingAllChannels(refresh: Bool) {
25912621 for (channel in getChannels()) {
2622+ if (channel.uiState == Creating) continue;
25922623 channel.selfPing(refresh || channel.disco == null);
25932624 }
25942625 }
......@@ -2596,6 +2627,7 @@ class Client extends EventEmitter {
25962627 private function joinAllChannels() {
25972628 channelPinger.clear();
25982629 for (channel in getChannels()) {
2630+ if (channel.uiState == Creating) continue;
25992631 if (channel.disco.identities.length < 1) {
26002632 channel.refreshDisco(() -> {
26012633 channel.join(true);
Back to top