git » sdk » commit 494510b

Always have self, add an isJoined property

author Eric Roberts
2026-10-07 15:34:46 UTC
committer Stephen Paul Weber
2026-10-07 15:36:17 UTC
parent 790849e47ccc9a6a6b04eba4a1433757103c54b2

Always have self, add an isJoined property

Previously we used self as a proxy for whether or not we were joined.
This meant that when joining a room and self wasn't set yet, or when
we've disconnected from a room but we still have it open in the UI,
sending a message could fallback to a nonsense username.

Now if it's before we've finished joining, we'll use the user's
preferred nick.

After we've joined ever, then we'll continue using the nick for that room.

I added joined and disconnected functions to handle the common pieces.
We weren't always removing ourselves from the channel pinger when we
should, so I added that to disconnected.

Most things that were previously checking self == null now check isJoined
instead, which is also a little more clear. A few places the check was
no longer necessary because we have a member object. Some tests assuming
things about disconnected users are no longer relevant, we will retain
the permissions we most recently knew about rather than clearing them.

borogove/Chat.hx +41 -28
borogove/Client.hx +2 -2
borogove/MemberUpdate.hx +1 -1
test/TestChat.hx +174 -19
test/TestClient.hx +3 -1
test/TestSqlite.hx +25 -13
test/idb.spec.ts +4 -2
test/persistence-tests.ts +5 -7
test/sqlite.spec.ts +4 -2

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 6089ef4..aa149ad 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -1535,7 +1535,8 @@ class Channel extends Chat {
 	private var sync: Null<MessageSync> = null;
 	private var forceLive = false;
 	@:allow(borogove)
-	private var self: Null<Member> = null;
+	private var self: Member;
+	private var isJoined(default, null): Bool = false;
 	@:allow(borogove)
 	private var mavUntil = null;
 	@:allow(borogove.SerializedChat)
@@ -1551,6 +1552,18 @@ class Channel extends Chat {
 			"SYNC: channel inSync",
 			{ reason: "initialize", chatId: chatId, inSync: inSync }
 		);
+		final nick = client.displayName();
+		final account = client.getDirectChat(client.accountId(), false);
+		self = new Member(
+			JID.parse(chatId).withResource(nick).asString(),
+			nick,
+			account.getPhoto(),
+			true,
+			[],
+			JID.parse(account.chatId),
+			new Map(),
+			new AvailableChat(account.chatId, account.getDisplayName(), account.chatId, CapsRepo.empty)
+		);
 		if (disco != null) {
 			this.disco = disco;
 			if (!disco.features.contains("http://jabber.org/protocol/muc")) {
@@ -1612,8 +1625,7 @@ class Channel extends Chat {
 
 		return (justCleared ?  Promise.resolve(null) : persistence.clearMemberPresence(client.accountId(), chatId)).then(_ -> {
 			joinFailed = null;
-			self = null;
-			outbox.pause();
+			disconnected();
 			inSync = false;
 			trace(
 				"SYNC: channel inSync",
@@ -1671,7 +1683,7 @@ class Channel extends Chat {
 		Can the user set the Channel subject?
 	**/
 	public function canSetSubject() {
-		if (self == null || disco == null) return false;
+		if (!isJoined || disco == null) return false;
 
 		final it = self.presence.iterator();
 		if (!it.hasNext()) return false;
@@ -1719,8 +1731,6 @@ class Channel extends Chat {
 
 	override public function canInvite() {
 		if (!isPrivate()) return true;
-		if (self == null) return false;
-
 		final it = self.presence.iterator();
 		if (!it.hasNext()) return false;
 
@@ -1731,7 +1741,6 @@ class Channel extends Chat {
 
 	override public function canSend() {
 		if (!super.canSend()) return false;
-		if (self == null) return true;
 
 		final it = self.presence.iterator();
 		if (!it.hasNext()) return true;
@@ -1740,7 +1749,7 @@ class Channel extends Chat {
 	}
 
 	override public function canModerate() {
-		if (self == null || disco == null) return false;
+		if (!isJoined || disco == null) return false;
 
 		final it = self.presence.iterator();
 		if (!it.hasNext()) return false;
@@ -1764,8 +1773,6 @@ class Channel extends Chat {
 	}
 
 	private function isOwner() {
-		if (self == null) return false;
-
 		return self.roles.exists(r -> r.id == "owner");
 	}
 
@@ -1869,12 +1876,9 @@ class Channel extends Chat {
 		}
 		if (member.isSelf) {
 			if (presence.type == "unavailable") {
-				self = null;
-				outbox.pause();
+				disconnected();
 			} else {
-				self = member;
-				outbox.start();
-				client.channelPinger.schedule(this);
+				joined(member);
 			}
 			if (!noStore) client.trigger("chats/update", [this]);
 		}
@@ -2102,11 +2106,24 @@ class Channel extends Chat {
 		return lastMessageSenderName + ": " + super.preview();
 	}
 
+	private function joined(member: Member): Void {
+		self = member;
+		isJoined = true;
+		outbox.start();
+		client.channelPinger.schedule(this);
+	}
+
+	private function disconnected(): Void {
+		isJoined = false;
+		outbox.pause();
+		client.channelPinger.remove(this);
+	}
+
 	@:allow(borogove)
 	override private function livePresence() {
 		if (forceLive) return true;
 
-		return self != null;
+		return isJoined;
 	}
 
 	override public function syncing() {
@@ -2140,7 +2157,7 @@ class Channel extends Chat {
 	}
 
 	private function nickInUse() {
-		return self?.displayName ?? client.displayName();
+		return self.displayName;
 	}
 
 	@:allow(borogove)
@@ -2151,13 +2168,13 @@ class Channel extends Chat {
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function members() {
 		return persistence
-			.getMembers(client.accountId(), this, self == null ? false : self.roles.exists(r -> ["admin", "owner"].contains(r.id)))
+			.getMembers(client.accountId(), this, self.roles.exists(r -> ["admin", "owner"].contains(r.id)))
 			.then(members -> members.filter(member -> member.id != chatId));
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function getMemberDetails(memberIds: Array<String>) {
-		if (memberIds.length == 1 && self != null && memberIds[0] == self.id) return Promise.resolve([cast self]);
+		if (memberIds.length == 1 && memberIds[0] == self.id) return Promise.resolve([cast self]);
 
 		return persistence.getMemberDetails(client.accountId(), this, memberIds);
 	}
@@ -2212,7 +2229,7 @@ class Channel extends Chat {
 			final affRole = Role.forAffiliation(presence.mucUser.affiliation);
 			if (affRole != null) roles.unshift(affRole);
 		}
-		if (oneTen != null || id == self?.id) {
+		if (oneTen != null || id == self.id) {
 			final chat = client.getDirectChat(client.accountId(), false);
 			return new Member(
 				id,
@@ -2240,8 +2257,6 @@ class Channel extends Chat {
 	}
 
 	override public function availableRoles(member: Member):Array<Role> {
-		if (self == null) return [];
-
 		if (self.roles.exists(r -> r.id == "owner")) {
 			return ["owner", "admin", "none", "outcast"].filter(aff -> !member.roles.exists(r -> r.id == aff)).map(aff -> Role.forAffiliation(aff));
 		}
@@ -2256,8 +2271,6 @@ class Channel extends Chat {
 	}
 
 	override public function canRemoveRole(member: Member, role: Role):Bool {
-		if (self == null) return false;
-
 		if (self.roles.exists(r -> r.id == "owner")) {
 			return ["owner", "admin", "none", "outcast"].contains(role.id);
 		}
@@ -2377,7 +2390,7 @@ class Channel extends Chat {
 		}
 		final occupantId = stanza.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id");
 		message.senderId = occupantId == null ? stanza.attr.get("from") : chatId + "/" + occupantId; // MUC always needs full JIDs
-		if (message.senderId == self?.id) {
+		if (message.senderId == self.id) {
 			message.recipients = message.replyTo;
 			message.direction = MessageSent;
 		}
@@ -2390,7 +2403,7 @@ class Channel extends Chat {
 		message.timeReceived = Date.format(std.Date.now());
 		message.direction = MessageSent;
 		message.from = client.jid;
-		message.senderId = self?.id ?? getFullJid().asString(); // TODO: what if we aren't joined and self is null and this is an occupant id room?
+		message.senderId = self.id;
 		message.replyTo = [getFullJid()];
 		message.to = JID.parse(chatId);
 		message.recipients = [message.to];
@@ -2486,11 +2499,11 @@ class Channel extends Chat {
 		final reactions = [];
 		for (areaction => reacts in m.reactions) {
 			if (areaction != reaction.key) {
-				final react = reacts.find(r -> r.senderId == self?.id);
+				final react = reacts.find(r -> r.senderId == self.id);
 				if (react != null && !Std.isOfType(react, CustomEmojiReaction)) reactions.push(react);
 			}
 		}
-		final update = new ReactionUpdate(ID.unique(), m.serverId, m.chatId(), null, m.chatId(), self?.id, Date.format(std.Date.now()), reactions, EmojiReactions);
+		final update = new ReactionUpdate(ID.unique(), m.serverId, m.chatId(), null, m.chatId(), self.id, Date.format(std.Date.now()), reactions, EmojiReactions);
 		final outboxItem = outbox.newItem();
 		persistence.storeReaction(client.accountId(), update).then((stored) -> {
 			sendMessageStanza(update.asStanza(), outboxItem);
diff --git a/borogove/Client.hx b/borogove/Client.hx
index b69fb65..8197138 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -1101,14 +1101,14 @@ class Client extends EventEmitter {
 				// We resumed from disk so these must have been synced before
 				// and will get anything since live
 				for (channel in getChannels()) {
-					channel.inSync = channel.self != null && channel.sortId != null;
+					channel.inSync = channel.livePresence() && channel.sortId != null;
 					trace(
 						"SYNC: channel inSync",
 						{
 							reason: "resume from disk",
 							chatId: channel.chatId,
 							inSync: channel.inSync,
-							isJoined: channel.self != null,
+							livePresence: channel.livePresence(),
 							sortId: channel.sortId,
 						}
 					);
diff --git a/borogove/MemberUpdate.hx b/borogove/MemberUpdate.hx
index 70d532b..1ab9560 100644
--- a/borogove/MemberUpdate.hx
+++ b/borogove/MemberUpdate.hx
@@ -80,7 +80,7 @@ class MemberUpdate {
 						id,
 						jid,
 						item.attr.get("nick") ?? resource,
-						jid?.asString() == accountId || channel?.self?.id == id,
+						jid?.asString() == accountId || channel?.self.id == id,
 						Role.forAffiliation(aff),
 						stanza.name == "presence" ? [ resource ?? "" => stanza ] : new Map()
 					));
diff --git a/test/TestChat.hx b/test/TestChat.hx
index 615a8b6..71269cb 100644
--- a/test/TestChat.hx
+++ b/test/TestChat.hx
@@ -418,11 +418,11 @@ class TestChat extends utest.Test {
 		final client = new Client("test@example.com", persistence);
 		final chat = new borogove.Chat.Channel(client, client.stream, persistence, "channel@example.com");
 
-		chat.self = new Member("me", "myself", null, true, [new Role("member", "")], JID.parse("test@example.com"), new Map(), null);
+		chat.joined(new Member("me", "myself", null, true, [new Role("member", "")], JID.parse("test@example.com"), new Map(), null));
 		chat.commands().then(cmds -> {
 			Assert.equals(0, cmds.length);
 
-			chat.self = new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null);
+			chat.joined(new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null));
 			chat.commands().then(cmds2 -> {
 				Assert.equals(1, cmds2.length);
 				Assert.equals("Settings", cmds2[0].name);
@@ -450,23 +450,23 @@ class TestChat extends utest.Test {
 		chat.disco = new borogove.Caps("", [], ["urn:xmpp:message-moderate:1", "http://jabber.org/protocol/muc"], []);
 		Assert.isFalse(chat.canModerate());
 
-		chat.self = new Member("me", "myself", null, true, [], JID.parse("test@example.com"), new Map(), null);
+		chat.joined(new Member("me", "myself", null, true, [], JID.parse("test@example.com"), new Map(), null));
 		Assert.isFalse(chat.canModerate());
 
 		// Presence set but not moderator
-		chat.self = new Member(
+		chat.joined(new Member(
 			"me", "myself", null, true, [], JID.parse("test@example.com"),
 			["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "participant" }).up(), null)],
 			null
-		);
+		));
 		Assert.isFalse(chat.canModerate());
 
 		// Is moderator
-		chat.self = new Member(
+		chat.joined(new Member(
 			"me", "myself", null, true, [], JID.parse("test@example.com"),
 			["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "moderator" }).up(), null)],
 			null
-		);
+		));
 		Assert.isTrue(chat.canModerate());
 	}
 
@@ -479,11 +479,11 @@ class TestChat extends utest.Test {
 		Assert.isFalse(chat.canSetSubject());
 
 		// Self set but no disco
-		chat.self = new Member(
+		chat.joined(new Member(
 			"me", "myself", null, true, [], JID.parse("test@example.com"),
 			["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "participant" }).up(), null)],
 			null
-		);
+		));
 		Assert.isFalse(chat.canSetSubject());
 
 		chat.disco = new borogove.Caps("", [], [], []);
@@ -492,19 +492,19 @@ class TestChat extends utest.Test {
 		Assert.isFalse(chat.canSetSubject());
 
 		// Is moderator, subjectmod not set
-		chat.self = new Member(
+		chat.joined(new Member(
 			"me", "myself", null, true, [], JID.parse("test@example.com"),
 			["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "moderator" }).up(), null)],
 			null
-		);
+		));
 		Assert.isTrue(chat.canSetSubject());
 
 		// Is participant, subjectmod set
-		chat.self = new Member(
+		chat.joined(new Member(
 			"me", "myself", null, true, [], JID.parse("test@example.com"),
 			["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "participant" }).up(), null)],
 			null
-		);
+		));
 		chat.disco = new borogove.Caps("", [], [], [new Stanza("x", { xmlns: "jabber:x:data", type: "result" }).tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/muc#roominfo").up().tag("field", { "var": "muc#roominfo_subjectmod" }).textTag("value", "1").up()]);
 		Assert.isTrue(chat.canSetSubject());
 	}
@@ -547,6 +547,110 @@ class TestChat extends utest.Test {
 		chat.setSubject("New Thread Subject", "thread123");
 	}
 
+	public function testChannelPlaceholderSelf(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final account = client.getDirectChat(client.accountId(), false);
+		final channel = new Channel(client, client.stream, persistence, "channel@example.com");
+		final self = channel.self;
+
+		Assert.equals("channel@example.com/" + client.displayName(), self.id);
+		Assert.equals(client.displayName(), self.displayName);
+		Assert.equals(account.getPhoto(), self.photoUri);
+		Assert.equals(client.accountId(), self.jid.asString());
+		Assert.equals(account.chatId, self.chat.chatId);
+		Assert.equals(account.getDisplayName(), self.chat.displayName);
+		Assert.isTrue(self.isSelf);
+		Assert.same([], self.roles);
+		Assert.isFalse(self.presence.iterator().hasNext());
+		Assert.isFalse(channel.isJoined);
+		Assert.isFalse(channel.livePresence());
+		Assert.isFalse(channel.canSetSubject());
+		Assert.isFalse(channel.canModerate());
+		Assert.isFalse(channel.isOwner());
+		Assert.same([], channel.availableRoles(self));
+		channel.disco = new borogove.Caps("", [], ["http://jabber.org/protocol/muc", "muc_membersonly"], []);
+		Assert.isFalse(channel.canInvite());
+		final outgoing = channel.prepareOutgoingMessage(new ChatMessageBuilder());
+		Assert.equals(self.id, outgoing.senderId);
+		Assert.equals(self.id, outgoing.replyTo[0].asString());
+
+		channel.getMemberDetails([self.id]).then(members -> {
+			Assert.equals(1, members.length);
+			Assert.equals(self, members[0]);
+			async.done();
+		});
+	}
+
+	public function testServerSelfReplacesPlaceholder() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final channel = new Channel(client, client.stream, persistence, "channel@example.com");
+		final placeholder = channel.self;
+		final presence = new Stanza("presence", { xmlns: "jabber:client", from: "channel@example.com/server-nick", to: "test@example.com" })
+			.tag("occupant-id", { xmlns: "urn:xmpp:occupant-id:0", id: "server-id" }).up()
+			.tag("x", { xmlns: "http://jabber.org/protocol/muc#user" })
+				.tag("item", { affiliation: "owner", role: "moderator" }).up()
+				.tag("status", { code: "110" }).up()
+			.up();
+
+		channel.setPresence("server-nick", cast presence, false);
+
+		Assert.isTrue(channel.isJoined);
+		Assert.isFalse(placeholder == channel.self);
+		Assert.equals("channel@example.com/server-id", channel.self.id);
+		Assert.equals("server-nick", channel.self.displayName);
+		Assert.isTrue(channel.isOwner());
+		Assert.isTrue(channel.self.presence.iterator().hasNext());
+	}
+
+	public function testDisconnectedChannelRetainsRolePermissions(async: Async) {
+		final persistence = new ChannelMembersPersistence();
+		final client = new Client("test@example.com", persistence);
+		final channel = new Channel(client, client.stream, persistence, "channel@example.com");
+		channel.disco = new borogove.Caps("", [], ["http://jabber.org/protocol/muc", "muc_membersonly", "urn:xmpp:message-moderate:1"], []);
+		final owner = new Role("owner", "Owner");
+		final self = new Member(
+			"me", "myself", null, true, [owner], JID.parse("test@example.com"),
+			["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "moderator" }).up(), null)],
+			null
+		);
+		channel.joined(self);
+		Assert.isTrue(channel.canSetSubject());
+		Assert.isTrue(channel.canModerate());
+		Assert.isTrue(channel.canInvite());
+		Assert.isTrue(channel.isOwner());
+		Assert.isTrue(channel.availableRoles(self).length > 0);
+		Assert.isTrue(channel.canRemoveRole(self, owner));
+
+		channel.disconnected();
+		Assert.equals(self, channel.self);
+		Assert.isFalse(channel.canSetSubject());
+		Assert.isFalse(channel.canModerate());
+		Assert.isTrue(channel.canInvite());
+		Assert.isTrue(channel.isOwner());
+		Assert.isTrue(channel.availableRoles(self).length > 0);
+		Assert.isTrue(channel.canRemoveRole(self, owner));
+		channel.members().then(_ -> {
+			Assert.isTrue(persistence.forModerator);
+			async.done();
+		});
+	}
+
+	public function testDisconnectedVisitorCannotQueueMessages() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final channel = new Channel(client, client.stream, persistence, "channel@example.com");
+		channel.joined(new Member(
+			"me", "myself", null, true, [], JID.parse("test@example.com"),
+			["myself" => new borogove.Presence(null, new Stanza("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("item", { role: "visitor" }).up(), null)],
+			null
+		));
+		Assert.isFalse(channel.canSend());
+		channel.disconnected();
+		Assert.isFalse(channel.canSend());
+	}
+
 	public function testJoinFailure() {
 		final persistence = new Dummy();
 		final client = new Client("test@example.com", persistence);
@@ -565,6 +669,7 @@ class TestChat extends utest.Test {
 
 		Assert.equals(channel.joinFailed, errorStanza, "joinFailed should be set");
 		Assert.isFalse(channel.syncing(), "Should NOT be syncing after join failure");
+		Assert.isFalse(channel.isJoined);
 	}
 
 	public function testJoinSuccess() {
@@ -574,7 +679,8 @@ class TestChat extends utest.Test {
 		final availableChat = new AvailableChat("channel@example.com", "Channel", "", caps);
 		final channel = cast(client.startChat(availableChat), Channel);
 
-		Assert.isNull(channel.self);
+		Assert.notNull(channel.self);
+		Assert.isFalse(channel.isJoined);
 
 		final joinedStanza = new Stanza("presence", { xmlns: "jabber:client", from: "channel@example.com/test", to: "test@example.com" })
 			.tag("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("status", { code: "110" }).up()
@@ -583,6 +689,55 @@ class TestChat extends utest.Test {
 		client.stream.onStanza(joinedStanza);
 
 		Assert.notNull(channel.self);
+		Assert.isTrue(channel.isJoined);
+		final self = channel.self;
+
+		final unavailableStanza = new Stanza("presence", { xmlns: "jabber:client", from: "channel@example.com/test", to: "test@example.com", type: "unavailable" })
+			.tag("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("status", { code: "110" }).up()
+			.up();
+
+		client.stream.onStanza(unavailableStanza);
+
+		Assert.isFalse(channel.isJoined);
+		Assert.equals(self, channel.self, "Unavailable presence should retain self");
+		Assert.isFalse(channel.livePresence());
+	}
+
+	public function testRejoinRetainsSelf(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final channel = new Channel(client, client.stream, persistence, "channel@example.com");
+		final joinedStanza = new Stanza("presence", { xmlns: "jabber:client", from: "channel@example.com/test", to: "test@example.com" })
+			.tag("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("status", { code: "110" }).up()
+			.up();
+		channel.setPresence("test", cast joinedStanza, false);
+		final self = channel.self;
+		Assert.isTrue(channel.isJoined);
+
+		channel.join().then(_ -> {
+			Assert.isFalse(channel.isJoined);
+			Assert.equals(self, channel.self, "Rejoining should retain self until new presence arrives");
+
+			channel.setPresence("test", cast joinedStanza, false);
+			Assert.isTrue(channel.isJoined);
+			Assert.notNull(channel.self);
+			Assert.isFalse(self == channel.self, "New self presence should replace the cached member");
+			async.done();
+		});
+	}
+
+	public function testRestoredSelfPresenceJoinsChannel() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final channel = new Channel(client, client.stream, persistence, "channel@example.com");
+		final joinedStanza = new Stanza("presence", { xmlns: "jabber:client", from: "channel@example.com/test", to: "test@example.com" })
+			.tag("x", { xmlns: "http://jabber.org/protocol/muc#user" }).tag("status", { code: "110" }).up()
+			.up();
+
+		Assert.isFalse(channel.isJoined);
+		channel.setPresence("test", cast joinedStanza, true);
+		Assert.isTrue(channel.isJoined);
+		Assert.notNull(channel.self);
 	}
 
 	public function testSyncFailure(async: Async) {
@@ -641,7 +796,7 @@ class TestChat extends utest.Test {
 		final persistence = new Dummy();
 		final client = new Client("test@example.com", persistence);
 		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
-		chat.self = new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null);
+		chat.joined(new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null));
 
 		final roles = chat.availableRoles(new Member("other", "other", null, true, [], JID.parse("test@example.com"), new Map(), null));
 		final ids = roles.map(r -> r.id);
@@ -655,7 +810,7 @@ class TestChat extends utest.Test {
 		final persistence = new Dummy();
 		final client = new Client("test@example.com", persistence);
 		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
-		chat.self = new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null);
+		chat.joined(new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null));
 
 		final roles = chat.availableRoles(new Member("other", "other", null, true, [new Role("none", "")], JID.parse("test@example.com"), new Map(), null));
 		final ids = roles.map(r -> r.id);
@@ -739,7 +894,7 @@ class TestChat extends utest.Test {
 		final persistence = new ChannelMembersPersistence();
 		final client = new Client("test@example.com", persistence);
 		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
-		chat.self = new Member("me", "myself", null, true, [new Role("admin", "Admin")], JID.parse("test@example.com"), new Map(), null);
+		chat.joined(new Member("me", "myself", null, true, [new Role("admin", "Admin")], JID.parse("test@example.com"), new Map(), null));
 
 		chat.members().then(members -> {
 			Assert.isTrue(persistence.forModerator);
@@ -772,10 +927,10 @@ class TestChat extends utest.Test {
 
 		Assert.isFalse(chat.canSetPhoto());
 
-		chat.self = new Member("me", "myself", null, true, [new Role("member", "")], JID.parse("test@example.com"), new Map(), null);
+		chat.joined(new Member("me", "myself", null, true, [new Role("member", "")], JID.parse("test@example.com"), new Map(), null));
 		Assert.isFalse(chat.canSetPhoto());
 
-		chat.self = new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null);
+		chat.joined(new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null));
 		Assert.isTrue(chat.canSetPhoto());
 	}
 
diff --git a/test/TestClient.hx b/test/TestClient.hx
index 8b0fa6e..457711d 100644
--- a/test/TestClient.hx
+++ b/test/TestClient.hx
@@ -1116,7 +1116,9 @@ class TestClient extends utest.Test {
 		});
 
 		client.startChatWith("room@example.com", _->Open, _->{
-			Assert.equals(1, client.chats.length);
+			final rooms = client.chats.filter(c -> c.chatId == "room@example.com");
+			Assert.equals(1, rooms.length);
+			Assert.equals(chat, rooms[0]);
 			async.done();
 		});
 	}
diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx
index 7e76a97..9052420 100644
--- a/test/TestSqlite.hx
+++ b/test/TestSqlite.hx
@@ -18,6 +18,7 @@ import borogove.JID;
 import borogove.ID;
 import borogove.Message;
 import borogove.Chat;
+import borogove.Client;
 import borogove.Chat.AvailableChat;
 import borogove.Status;
 import borogove.Reaction;
@@ -1245,7 +1246,8 @@ class TestSqlite extends utest.Test {
 
 	public function testGetChatsHydratesMembersForNameAndMavUntil(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-chat-hydrate@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-chat-hydrate@example.com");
 		chat.displayName = "Tea Room";
 		chat.mavUntil = "2024-05-01T12:00:00Z";
 
@@ -1304,7 +1306,7 @@ class TestSqlite extends utest.Test {
 		]).then(_ -> {
 			haxe.Timer.delay(() -> {
 				persistence.getChats(account).then(chats -> {
-					final stored = chats[0];
+					final stored = chats.filter(stored -> stored.chatId == chat.chatId)[0];
 					Assert.notNull(stored.membersForName);
 					Assert.equals("2024-05-01T12:00:00Z", stored.mavUntil);
 					Assert.equals(2, stored.membersForName.length);
@@ -1340,7 +1342,8 @@ class TestSqlite extends utest.Test {
 
 	public function testGetMembersHydratesPersistedMemberData(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-members-1@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-members-1@example.com");
 		chat.displayName = "A Chat";
 		chat.trusted = true;
 		final member = new Member(
@@ -1373,7 +1376,8 @@ class TestSqlite extends utest.Test {
 
 	public function testStoreMemberUpdatesMergesExistingMemberData(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-members-2@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-members-2@example.com");
 		chat.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat.chatId, [
@@ -1414,7 +1418,8 @@ class TestSqlite extends utest.Test {
 
 	public function testStoreMemberUpdatesClearsOmittedFullListAffiliations(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-members-2b@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-members-2b@example.com");
 		chat.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat.chatId, [
@@ -1470,7 +1475,8 @@ class TestSqlite extends utest.Test {
 
 	public function testStoreMemberUpdatesMatchesExistingMemberByTrueJid(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-members-3@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-members-3@example.com");
 		chat.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat.chatId, [
@@ -1509,9 +1515,10 @@ class TestSqlite extends utest.Test {
 
 	public function testClearMemberPresenceOnlyClearsTargetedChat(async: Async) {
 		final account = "alice@example.com";
-		final chat1 = new Channel(cast null, cast null, persistence, "room-members-4a@example.com");
+		final client = new Client(account, persistence);
+		final chat1 = new Channel(client, client.stream, persistence, "room-members-4a@example.com");
 		chat1.displayName = "A Chat";
-		final chat2 = new Channel(cast null, cast null, persistence, "room-members-4b@example.com");
+		final chat2 = new Channel(client, client.stream, persistence, "room-members-4b@example.com");
 		chat2.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat1.chatId, [
@@ -1560,7 +1567,8 @@ class TestSqlite extends utest.Test {
 
 	public function testGetMembersFiltersHiddenRowsForNonModerators(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-members-5@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-members-5@example.com");
 		chat.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat.chatId, [
@@ -1581,7 +1589,8 @@ class TestSqlite extends utest.Test {
 
 	public function testGetMembersTreatsEmptyPresenceAsOffline(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-members-empty-presence@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-members-empty-presence@example.com");
 		chat.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat.chatId, [
@@ -1608,7 +1617,8 @@ class TestSqlite extends utest.Test {
 
 	public function testGetMembersIncludesModeratorVisibleRows(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-members-6@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-members-6@example.com");
 		chat.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat.chatId, [
@@ -1629,7 +1639,8 @@ class TestSqlite extends utest.Test {
 
 	public function testGetMemberDetailsReturnsNullForIncompleteRows(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-members-7@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-members-7@example.com");
 		chat.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat.chatId, [
@@ -1673,7 +1684,8 @@ class TestSqlite extends utest.Test {
 
 	public function testVoiceRequests(async: Async) {
 		final account = "alice@example.com";
-		final chat = new Channel(cast null, cast null, persistence, "room-voice-requests@example.com");
+		final client = new Client(account, persistence);
+		final chat = new Channel(client, client.stream, persistence, "room-voice-requests@example.com");
 		chat.displayName = "A Chat";
 
 		persistence.storeMembers(account, chat.chatId, [
diff --git a/test/idb.spec.ts b/test/idb.spec.ts
index 98e71f5..7979960 100644
--- a/test/idb.spec.ts
+++ b/test/idb.spec.ts
@@ -9,8 +9,10 @@ async function idbFixture() {
 		`${databaseName}-media`,
 	);
 	const persistence = await borogove.persistence.IDB(databaseName, mediaStore);
-	const createChannel = (p: any, chatId: string) =>
-		new borogove.Channel(null, null, p, chatId);
+	const createChannel = (p: any, chatId: string) => {
+		const client = new borogove.Client("alice@example.com", p);
+		return new borogove.Channel(client, client.stream, p, chatId);
+	};
 	const storeIncompleteMember = async (chatId: string) => {
 		const request = indexedDB.open(databaseName);
 		const db = await new Promise<IDBDatabase>((resolve, reject) => {
diff --git a/test/persistence-tests.ts b/test/persistence-tests.ts
index 0d1a01b..0aa77b1 100644
--- a/test/persistence-tests.ts
+++ b/test/persistence-tests.ts
@@ -230,7 +230,9 @@ export function sharedPersistenceTests(test) {
 				chat: { chatId: "hidden@example.com" },
 			},
 		]);
-		const [stored] = await persistence.getChats("alice@example.com");
+		const stored = (await persistence.getChats("alice@example.com")).find(
+			(storedChat) => storedChat.chatId === chat.chatId,
+		);
 		expect(stored.mavUntil).toBe("2024-05-01T12:00:00Z");
 		expect(stored.membersForName.map((m) => m.displayName)).toEqual([
 			"Alpha",
@@ -1624,13 +1626,9 @@ export function sharedPersistenceTests(test) {
 	test("getMembers filters hidden rows for non-moderators", async ({
 		borogove,
 		persistence,
+		createChannel,
 	}) => {
-		const chat = new borogove.Channel(
-			null,
-			null,
-			persistence,
-			"room-members-5@example.com",
-		);
+		const chat = createChannel(persistence, "room-members-5@example.com");
 		chat.displayName = "Tea Room";
 		chat.trusted = true;
 
diff --git a/test/sqlite.spec.ts b/test/sqlite.spec.ts
index 3efcccc..84885f6 100644
--- a/test/sqlite.spec.ts
+++ b/test/sqlite.spec.ts
@@ -30,8 +30,10 @@ async function sqliteFixture() {
 		storeChats(...args);
 		return new Promise((resolve) => setTimeout(resolve, 200));
 	};
-	const createChannel = (p: any, chatId: string) =>
-		new sqlite.Channel(null, null, p, chatId);
+	const createChannel = (p: any, chatId: string) => {
+		const client = new borogove.Client("alice@example.com", p);
+		return new sqlite.Channel(client, client.stream, p, chatId);
+	};
 	const storeIncompleteMember = async (chatId: string) => {
 		await persistence.db.exec(
 			"INSERT INTO members(account_id, chat_id, member_id, display_name, photo_uri, is_self, chat, roles, presence, jid) VALUES(?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?)",