git » sdk » commit a7eaa90

Invited uistate

author Stephen Paul Weber
2025-11-29 03:41:23 UTC
committer Stephen Paul Weber
2025-11-29 03:41:23 UTC
parent ac18ccd2448c2e683b8f644f1ca368a739b93a43

Invited uistate

For presence requests and MUC invites, set to "Invited" which means not
quite Open but not quite Closed. Appears in getChats just like
Open/Pinned but MUC doesn't join, etc.

Invited transitions to Open when you send or receive a message or call,
or set trusted or bookmark the chat. Transitions to closed when you call close.

borogove/Chat.hx +38 -6
borogove/Client.hx +54 -6
borogove/Message.hx +19 -0

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 9d55226..94eb96f 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -32,6 +32,7 @@ enum abstract UiState(Int) {
 	var Pinned;
 	var Open; // or Unspecified
 	var Closed; // Archived
+	var Invited;
 }
 
 enum abstract UserState(Int) {
@@ -335,7 +336,7 @@ abstract class Chat {
 		Pin or unpin this chat
 	**/
 	public function togglePinned(): Void {
-		uiState = uiState == Pinned ? Open : Pinned;
+		uiState = uiState != Pinned ? Pinned : Open;
 		persistence.storeChats(client.accountId(), [this]);
 		client.sortChats();
 		client.trigger("chats/update", [this]);
@@ -441,6 +442,7 @@ abstract class Chat {
 	private function updateFromRoster(item: { fn: Null<String>, subscription: String }) {
 		setTrusted(item.subscription == "both" || item.subscription == "from");
 		if (item.fn != null && item.fn != "") displayName = item.fn;
+		if (uiState == Invited) uiState = Open;
 	}
 
 	/**
@@ -520,9 +522,11 @@ abstract class Chat {
 					p == null || p.isSelf ? null : p.displayName;
 				}).filter(fn -> fn != null).join(", ");
 			}
+		} else if (uiState == Invited) {
+			return '${displayName} (${chatId})';
 		}
 
-		return this.displayName;
+		return displayName;
 	}
 
 	@:allow(borogove)
@@ -575,6 +579,10 @@ abstract class Chat {
 	**/
 	public function setTrusted(trusted:Bool) {
 		this.trusted = trusted;
+		if (trusted && uiState == Invited) {
+			uiState = Open;
+			client.trigger("chats/update", [this]);
+		}
 	}
 
 	/**
@@ -628,6 +636,7 @@ abstract class Chat {
 		@param video do we want video in this call
 	**/
 	public function startCall(audio: Bool, video: Bool) {
+		if (uiState == Invited) uiState = Open;
 		final session = new OutgoingProposedSession(client, JID.parse(chatId));
 		jingleSessions.set(session.sid, session);
 		session.propose(audio, video);
@@ -644,6 +653,7 @@ abstract class Chat {
 		Accept any incoming calls in this Chat
 	**/
 	public function acceptCall() {
+		if (uiState == Invited) uiState = Open;
 		for (session in jingleSessions) {
 			session.accept();
 		}
@@ -968,6 +978,7 @@ class DirectChat extends Chat {
 
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function sendMessage(message: ChatMessageBuilder):Void {
+		if (uiState == Invited) uiState = Open;
 		if (typingTimer != null) typingTimer.stop();
 		client.chatActivity(this);
 		message = prepareOutgoingMessage(message);
@@ -1095,6 +1106,10 @@ class DirectChat extends Chat {
 
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function bookmark() {
+		if (uiState == Invited) {
+			uiState = Open;
+			client.trigger("chats/update", [this]);
+		}
 		final attr: DynamicAccess<String> = { jid: chatId };
 		if (displayName != null && displayName != "" && displayName != chatId) {
 			attr["name"] = displayName;
@@ -1134,7 +1149,10 @@ class DirectChat extends Chat {
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function close() {
 		if (typingTimer != null) typingTimer.stop();
-		// Should this remove from roster?
+		if (uiState == Invited) {
+			client.sendStanza(new Stanza("presence", { to: chatId, type: "unsubscribed", id: ID.short() }));
+		}
+		// Should this remove from roster? Or set untrusted?
 		uiState = Closed;
 		persistence.storeChats(client.accountId(), [this]);
 		if (!isBlocked) sendChatState("gone", null);
@@ -1170,7 +1188,9 @@ class Channel extends Chat {
 
 	@:allow(borogove)
 	private function selfPing(refresh: Bool) {
-		if (uiState == Closed){
+		if (uiState == Invited) return;
+
+		if (uiState == Closed) {
 			client.sendPresence(
 				getFullJid().asString(),
 				(stanza) -> {
@@ -1209,6 +1229,11 @@ class Channel extends Chat {
 
 	@:allow(borogove)
 	private function join() {
+		if (uiState == Invited || uiState == Closed) {
+			// Do not join
+			return;
+		}
+
 		presence = []; // About to ask for a fresh set
 		_nickInUse = null;
 		outbox.pause();
@@ -1400,8 +1425,13 @@ class Channel extends Chat {
 		sync.fetchNext();
 	}
 
+	override public function setTrusted(trusted: Bool) {
+		super.setTrusted(trusted);
+		if (trusted) selfPing(true);
+	}
+
 	override public function isTrusted() {
-		return uiState != Closed;
+		return uiState != Closed && uiState != Invited;
 	}
 
 	public function isPrivate() {
@@ -1571,6 +1601,7 @@ class Channel extends Chat {
 
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function sendMessage(message:ChatMessageBuilder):Void {
+		if (uiState == Invited) uiState = Open;
 		if (typingTimer != null) typingTimer.stop();
 		client.chatActivity(this);
 		message = prepareOutgoingMessage(message);
@@ -1668,12 +1699,13 @@ class Channel extends Chat {
 
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function bookmark() {
+		if (uiState == Invited) uiState = Open;
 		stream.sendIq(
 			new Stanza("iq", { type: "set" })
 				.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" })
 				.tag("publish", { node: "urn:xmpp:bookmarks:1" })
 				.tag("item", { id: chatId })
-				.tag("conference", { xmlns: "urn:xmpp:bookmarks:1", name: getDisplayName(), autojoin: uiState == Closed ? "false" : "true" })
+				.tag("conference", { xmlns: "urn:xmpp:bookmarks:1", name: getDisplayName(), autojoin: uiState == Closed || uiState == Invited ? "false" : "true" })
 				.textTag("nick", client.displayName()) // Redundant but some other clients want it
 				.addChild(extensions)
 				.up().up()
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 866c629..86f6fa1 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -413,6 +413,20 @@ class Client extends EventEmitter {
 				this.trigger("chats/update", [chat]);
 			}
 
+			if (stanza.attr.get("from") != null && stanza.attr.get("type") == "subscribe") {
+				final from = JID.parse(stanza.attr.get("from"));
+				final chat = getChat(from.asBare().asString());
+				final nick = stanza.getChildText("nick", "http://jabber.org/protocol/nick");
+				if (chat == null) {
+					startChatWith(from.asBare().asString(), _-> Invited, (chat) -> {
+						if (chat.displayName == chat.chatId && nick != null) chat.displayName = nick;
+					});
+				} else if (chat.uiState == Closed) {
+					chat.uiState = Invited;
+					if (chat.displayName == chat.chatId && nick != null) chat.displayName = nick;
+				}
+			}
+
 			return EventUnhandled;
 		});
 	}
@@ -469,6 +483,35 @@ class Client extends EventEmitter {
 					MessageFailedToSend,
 					stanza.getErrorText(),
 				).then((m) -> notifyMessageHandlers(m, StatusEvent), _ -> null);
+			case MucInviteStanza(serverId, serverIdBy, reason, password):
+				final chat = getChat(message.chatId);
+				if (chat == null) {
+					startChatWith(message.chatId, _ -> Invited, (chat) -> {
+						final inviteExt = chat.extensions.tag("invite", { xmlns: "http://jabber.org/protocol/muc#user", from: message.senderId });
+						if (reason != null) inviteExt.textTag("reason", reason);
+						if (password != null) inviteExt.textTag("password", password);
+						if (message.threadId != null) inviteExt.tag("continue", { thread: message.threadId }).up();
+						if (serverId != null && serverIdBy != null) {
+							inviteExt.tag("stanza-id", { xmlns: "urn:xmpp:sid:0", by: serverIdBy, id: serverId }).up();
+						}
+						inviteExt.up();
+						this.trigger("chats/update", [chat]);
+						persistence.storeChats(accountId(), [chat]);
+					});
+				} else if (chat.uiState == Closed) {
+					chat.extensions.removeChildren("invite", "http://jabber.org/protocol/muc#user");
+					final inviteExt = chat.extensions.tag("invite", { xmlns: "http://jabber.org/protocol/muc#user", from: message.senderId });
+					if (reason != null) inviteExt.textTag("reason", reason);
+					if (password != null) inviteExt.textTag("password", password);
+					if (message.threadId != null) inviteExt.tag("continue", { thread: message.threadId }).up();
+					if (serverId != null && serverIdBy != null) {
+						inviteExt.tag("stanza-id", { xmlns: "urn:xmpp:sid:0", by: serverIdBy, id: serverId }).up();
+					}
+					inviteExt.up();
+					chat.uiState = Invited;
+					this.trigger("chats/update", [chat]);
+					persistence.storeChats(accountId(), [chat]);
+				}
 			default:
 				// ignore
 				trace("Ignoring non-chat message: " + stanza.toString());
@@ -602,7 +645,7 @@ class Client extends EventEmitter {
 						final upTo = item.getChild("displayed", "urn:xmpp:mds:displayed:0")?.getChild("stanza-id", "urn:xmpp:sid:0");
 						final chat = getChat(item.attr.get("id"));
 						if (chat == null) {
-							startChatWith(item.attr.get("id"), (caps) -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")));
+							startChatWith(item.attr.get("id"), _ -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")));
 						} else {
 							chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")).then(_ -> {
 								persistence.storeChats(accountId(), [chat]);
@@ -1556,30 +1599,33 @@ class Client extends EventEmitter {
 		sendQuery(rosterGet);
 	}
 
-	private function startChatWith(jid: String, handleCaps: (Caps)->UiState, handleChat: (Chat)->Void) {
+	private function startChatWith(jid: String, handleCaps: (Null<Caps>)->UiState, handleChat: (Chat)->Void) {
 		final discoGet = new DiscoInfoGet(jid);
 		discoGet.onFinished(() -> {
 			final resultCaps = discoGet.getResult();
+			final uiState = handleCaps(resultCaps);
 			if (resultCaps == null) {
 				final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas");
 				if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") {
 					final chat = getDirectChat(jid, false);
+					chat.uiState = uiState;
 					handleChat(chat);
 					persistence.storeChats(accountId(), [chat]);
+					this.trigger("chats/update", [chat]);
 				}
 			} else {
 				persistence.storeCaps(resultCaps);
-				final uiState = handleCaps(resultCaps);
 				if (resultCaps.isChannel(jid)) {
 					final chat = new Channel(this, this.stream, this.persistence, jid, uiState, false, null, resultCaps);
 					chat.setupNotifications();
-					handleChat(chat);
 					chats.unshift(chat);
+					if (inSync && sendAvailable) chat.selfPing(false);
+					handleChat(chat);
 					persistence.storeChats(accountId(), [chat]);
 					this.trigger("chats/update", [chat]);
-					if (inSync && sendAvailable) chat.selfPing(false);
 				} else {
 					final chat = getDirectChat(jid, false);
+					chat.uiState = uiState;
 					handleChat(chat);
 					persistence.storeChats(accountId(), [chat]);
 					this.trigger("chats/update", [chat]);
@@ -1612,7 +1658,7 @@ class Client extends EventEmitter {
 					final upTo = item.getChild("displayed", "urn:xmpp:mds:displayed:0")?.getChild("stanza-id", "urn:xmpp:sid:0");
 					final chat = getChat(item.attr.get("id"));
 					if (chat == null) {
-						startChatWith(item.attr.get("id"), (caps) -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")));
+						startChatWith(item.attr.get("id"), _ -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")));
 					} else {
 						chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")).then(_ -> null, e -> e != null ? Promise.reject(e) : null);
 						chatsToUpdate.push(chat);
@@ -1633,6 +1679,8 @@ class Client extends EventEmitter {
 						startChatWith(
 							item.attr.get("id"),
 							(caps) -> {
+								if (caps == null) return Open;
+
 								final identity = caps.identities[0];
 								final conf = item.getChild("conference", "urn:xmpp:bookmarks:1");
 								if (conf.attr.get("name") == null) {
diff --git a/borogove/Message.hx b/borogove/Message.hx
index 498b488..e36c186 100644
--- a/borogove/Message.hx
+++ b/borogove/Message.hx
@@ -28,6 +28,7 @@ enum MessageStanza {
 	ChatMessageStanza(message: ChatMessage);
 	ModerateMessageStanza(action: ModerationAction);
 	ReactionUpdateStanza(update: ReactionUpdate);
+	MucInviteStanza(serverId: Null<String>, serverIdBy: Null<String>, reason: Null<String>, password: Null<String>);
 	UnknownMessageStanza(stanza: Stanza);
 }
 
@@ -117,6 +118,24 @@ class Message {
 		if (msg.from != null && msg.from.asBare().equals(localJidBare)) msg.direction = MessageSent;
 		msg.status = msg.direction == MessageReceived ? MessageDeliveredToDevice : MessageDeliveredToServer; // Delivered to us, a device
 
+		final mucDirectInvite = stanza.getChild("x", "jabber:x:conference");
+		if (mucDirectInvite != null) {
+			final mucJid = mucDirectInvite.attr.get("jid");
+			if (mucJid != null) {
+				return new Message(mucJid, from, mucDirectInvite.attr.get("thread"), MucInviteStanza(msg.serverId, msg.serverIdBy, mucDirectInvite.attr.get("reason"), mucDirectInvite.attr.get("password")), encryptionInfo);
+			}
+		}
+
+		final mucUser = stanza.getChild("x", "http://jabber.org/protocol/muc#user");
+		final mucInvite = mucUser?.getChild("invite");
+		if (mucInvite != null) {
+			final threadId = mucInvite.getChild("continue")?.attr?.get("thread");
+			final reason = mucInvite.getChildText("reason");
+			final password = mucInvite.getChildText("password");
+			// NOTE: this senderId is unverified and unverifiable
+			return new Message(from, mucInvite.attr.get("from") ?? from, threadId, MucInviteStanza(msg.serverId, msg.serverIdBy, reason, password), encryptionInfo);
+		}
+
 		final recipients: Map<String, Bool> = [];
 		final replyTo: Map<String, Bool> = [];
 		if (msg.to != null) {